Skip to content

Commit

Permalink
Initial HTTP/3 implementation
Browse files Browse the repository at this point in the history
Since quicer, which provides the QUIC implementation,
is a NIF, Gun cannot depend directly on it. In order
to enable QUIC and HTTP/3, users have to set the
GUN_QUICER environment variable:

  export GUN_QUICER=1

Gun is now tested using GitHub Actions. As a result
OTP-24+ is now required. In addition, the number
of OTP releases tested has been reduced; only the
latest of each major version is now tested.

This also updates Erlang.mk.
  • Loading branch information
essen committed Mar 26, 2024
1 parent e2ff718 commit 6dd58a4
Show file tree
Hide file tree
Showing 11 changed files with 1,164 additions and 24 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Use workflows from ninenines/ci.erlang.mk to test Gun.

name: Check Gun

on:
push:
branches:
- master
pull_request:
schedule:
## Every Monday at 2am.
- cron: 0 2 * * 1

env:
CI_ERLANG_MK: 1

jobs:
cleanup-master:
name: Cleanup master build
runs-on: ubuntu-latest
steps:

- name: Cleanup master build if necessary
if: ${{ github.event_name == 'schedule' }}
run: |
gh extension install actions/gh-actions-cache
gh actions-cache delete Linux-X64-Erlang-master -R $REPO --confirm || true
gh actions-cache delete macOS-X64-Erlang-master -R $REPO --confirm || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}

check:
name: Gun
needs: cleanup-master
uses: ninenines/ci.erlang.mk/.github/workflows/ci.yaml@master
25 changes: 19 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ CT_OPTS += -ct_hooks gun_ct_hook [] # -boot start_sasl
LOCAL_DEPS = public_key ssl

DEPS = cowlib
dep_cowlib = git https://github.com/ninenines/cowlib 2.13.0
dep_cowlib = git https://github.com/ninenines/cowlib master

ifeq ($(GUN_QUICER),1)
DEPS += quicer
dep_quicer = git https://github.com/emqx/quic main
endif

DOC_DEPS = asciideck

Expand All @@ -29,10 +34,8 @@ dep_ranch_commit = 2.0.0
dep_ci.erlang.mk = git https://github.com/ninenines/ci.erlang.mk master
DEP_EARLY_PLUGINS = ci.erlang.mk

AUTO_CI_OTP ?= OTP-22+
#AUTO_CI_HIPE ?= OTP-LATEST
# AUTO_CI_ERLLVM ?= OTP-LATEST
AUTO_CI_WINDOWS ?= OTP-22+
AUTO_CI_OTP ?= OTP-LATEST-24+
AUTO_CI_WINDOWS ?= OTP-LATEST-24+

# Hex configuration.

Expand All @@ -58,14 +61,24 @@ ifndef FULL
CT_SUITES := $(filter-out ws_autobahn,$(CT_SUITES))
endif

# Enable eunit.
# Compile options.

TEST_ERLC_OPTS += +'{parse_transform, eunit_autoexport}'

ifeq ($(GUN_QUICER),1)
ERLC_OPTS += -D GUN_QUICER=1
TEST_ERLC_OPTS += -D GUN_QUICER=1
endif

# Generate rebar.config on build.

app:: rebar.config

# Fix quicer compilation for HTTP/3.

autopatch-quicer::
$(verbose) printf "%s\n" "all: ;" > $(DEPS_DIR)/quicer/c_src/Makefile.erlang.mk

# h2specd setup.

GOPATH := $(ERLANG_MK_TMP)/gopath
Expand Down
2 changes: 1 addition & 1 deletion ebin/gun.app
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{application, 'gun', [
{description, "HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP."},
{vsn, "2.1.0"},
{modules, ['gun','gun_app','gun_conns_sup','gun_content_handler','gun_cookies','gun_cookies_list','gun_data_h','gun_default_event_h','gun_event','gun_http','gun_http2','gun_pool','gun_pool_events_h','gun_pools_sup','gun_protocols','gun_public_suffix','gun_raw','gun_socks','gun_sse_h','gun_sup','gun_tcp','gun_tcp_proxy','gun_tls','gun_tls_proxy','gun_tls_proxy_cb','gun_tls_proxy_http2_connect','gun_tunnel','gun_ws','gun_ws_h','gun_ws_protocol']},
{modules, ['gun','gun_app','gun_conns_sup','gun_content_handler','gun_cookies','gun_cookies_list','gun_data_h','gun_default_event_h','gun_event','gun_http','gun_http2','gun_http3','gun_pool','gun_pool_events_h','gun_pools_sup','gun_protocols','gun_public_suffix','gun_quicer','gun_raw','gun_socks','gun_sse_h','gun_sup','gun_tcp','gun_tcp_proxy','gun_tls','gun_tls_proxy','gun_tls_proxy_cb','gun_tls_proxy_http2_connect','gun_tunnel','gun_ws','gun_ws_h','gun_ws_protocol']},
{registered, [gun_sup]},
{applications, [kernel,stdlib,public_key,ssl,cowlib]},
{optional_applications, []},
Expand Down
4 changes: 2 additions & 2 deletions erlang.mk
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ERLANG_MK_FILENAME := $(realpath $(lastword $(MAKEFILE_LIST)))
export ERLANG_MK_FILENAME

ERLANG_MK_VERSION = 61f58ff
ERLANG_MK_VERSION = 16d60fa
ERLANG_MK_WITHOUT =

# Make 3.81 and 3.82 are deprecated.
Expand Down Expand Up @@ -3565,7 +3565,7 @@ REBAR_DEPS_DIR = $(DEPS_DIR)
export REBAR_DEPS_DIR

REBAR3_GIT ?= https://github.com/erlang/rebar3
REBAR3_COMMIT ?= 3f563feaf1091a1980241adefa83a32dd2eebf7c # 3.20.0
REBAR3_COMMIT ?= 06aaecd51b0ce828b66bb65a74d3c1fd7833a4ba # 3.22.1 + OTP-27 fixes

CACHE_DEPS ?= 0

Expand Down
2 changes: 1 addition & 1 deletion rebar.config
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{deps, [
{cowlib,".*",{git,"https://github.com/ninenines/cowlib","2.13.0"}}
{cowlib,".*",{git,"https://github.com/ninenines/cowlib","master"}}
]}.
{erl_opts, [debug_info,warn_export_vars,warn_shadow_vars,warn_obsolete_guard]}.
84 changes: 72 additions & 12 deletions src/gun.erl
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@
| {close, ws_close_code(), iodata()}.
-export_type([ws_frame/0]).

-type protocol() :: http | http2 | raw | socks
| {http, http_opts()} | {http2, http2_opts()} | {raw, raw_opts()} | {socks, socks_opts()}.
-type protocol() :: http | http2 | http3 | raw | socks
| {http, http_opts()} | {http2, http2_opts()} | {http3, http3_opts()}
| {raw, raw_opts()} | {socks, socks_opts()}.
-export_type([protocol/0]).

-type protocols() :: [protocol()].
Expand All @@ -141,6 +142,7 @@
event_handler => {module(), any()},
http_opts => http_opts(),
http2_opts => http2_opts(),
http3_opts => http3_opts(),
protocols => protocols(),
raw_opts => raw_opts(),
retry => non_neg_integer(),
Expand All @@ -153,7 +155,7 @@
tls_handshake_timeout => timeout(),
tls_opts => [ssl:tls_client_option()],
trace => boolean(),
transport => tcp | tls | ssl,
transport => tcp | tls | ssl | quic,
ws_opts => ws_opts()
}.
-export_type([opts/0]).
Expand Down Expand Up @@ -252,6 +254,11 @@
}.
-export_type([http2_opts/0]).

%% @todo
-type http3_opts() :: #{
}.
-export_type([http3_opts/0]).

-type socks_opts() :: #{
version => 5,
auth => [{username_password, binary(), binary()} | none],
Expand Down Expand Up @@ -391,6 +398,11 @@ check_options([{http2_opts, ProtoOpts}|Opts]) when is_map(ProtoOpts) ->
Error ->
Error
end;
check_options([{http3_opts, ProtoOpts}|Opts]) when is_map(ProtoOpts) ->
case gun_http3:check_options(ProtoOpts) of
ok ->
check_options(Opts)
end;
check_options([Opt = {protocols, L}|Opts]) when is_list(L) ->
case check_protocols_opt(L) of
ok -> check_options(Opts);
Expand Down Expand Up @@ -428,7 +440,7 @@ check_options([{tls_opts, L}|Opts]) when is_list(L) ->
check_options(Opts);
check_options([{trace, B}|Opts]) when is_boolean(B) ->
check_options(Opts);
check_options([{transport, T}|Opts]) when T =:= tcp; T =:= tls ->
check_options([{transport, T}|Opts]) when T =:= tcp; T =:= tls; T =:= quic ->
check_options(Opts);
check_options([{ws_opts, ProtoOpts}|Opts]) when is_map(ProtoOpts) ->
case gun_ws:check_options(ProtoOpts) of
Expand All @@ -442,9 +454,9 @@ check_options([Opt|_]) ->

check_protocols_opt(Protocols) ->
%% Protocols must not appear more than once, and they
%% must be one of http, http2 or socks.
%% must be one of http, http2, http3, raw or socks.
ProtoNames0 = lists:usort([case P0 of {P, _} -> P; P -> P end || P0 <- Protocols]),
ProtoNames = [P || P <- ProtoNames0, lists:member(P, [http, http2, raw, socks])],
ProtoNames = [P || P <- ProtoNames0, lists:member(P, [http, http2, http3, raw, socks])],
case length(Protocols) =:= length(ProtoNames) of
false -> error;
true ->
Expand All @@ -453,6 +465,7 @@ check_protocols_opt(Protocols) ->
TupleCheck = [case P of
{http, Opts} -> gun_http:check_options(Opts);
{http2, Opts} -> gun_http2:check_options(Opts);
{http3, Opts} -> gun_http3:check_options(Opts);
{raw, Opts} -> gun_raw:check_options(Opts);
{socks, Opts} -> gun_socks:check_options(Opts)
end || P <- Protocols, is_tuple(P)],
Expand All @@ -468,6 +481,7 @@ consider_tracing(ServerPid, #{trace := true}) ->
_ = dbg:tpl(gun, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_http, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_http2, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_http3, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_raw, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_socks, [{'_', [], [{return_trace}]}]),
_ = dbg:tpl(gun_ws, [{'_', [], [{return_trace}]}]),
Expand Down Expand Up @@ -495,6 +509,7 @@ info(ServerPid) ->
Info0 = #{
owner => Owner,
socket => Socket,
%% @todo This is no longer correct for https because of QUIC.
transport => case OriginScheme of
<<"http">> -> tcp;
<<"https">> -> tls
Expand Down Expand Up @@ -818,7 +833,7 @@ await_body(ServerPid, StreamRef, Timeout, MRef, Acc) ->
end.

-spec await_up(pid())
-> {ok, http | http2 | raw | socks}
-> {ok, http | http2 | http3 | raw | socks}
| {error, {down, any()} | timeout}.
await_up(ServerPid) ->
MRef = monitor(process, ServerPid),
Expand All @@ -827,7 +842,7 @@ await_up(ServerPid) ->
Res.

-spec await_up(pid(), reference() | timeout())
-> {ok, http | http2 | raw | socks}
-> {ok, http | http2 | http3 | raw | socks}
| {error, {down, any()} | timeout}.
await_up(ServerPid, MRef) when is_reference(MRef) ->
await_up(ServerPid, 5000, MRef);
Expand All @@ -838,7 +853,7 @@ await_up(ServerPid, Timeout) ->
Res.

-spec await_up(pid(), timeout(), reference())
-> {ok, http | http2 | raw | socks}
-> {ok, http | http2 | http3 | raw | socks}
| {error, {down, any()} | timeout}.
await_up(ServerPid, Timeout, MRef) ->
receive
Expand Down Expand Up @@ -974,7 +989,8 @@ init({Owner, Host, Port, Opts}) ->
%% This is corrected in the gun:info/1 and gun:stream_info/2 functions where applicable.
{OriginScheme, Transport} = case OriginTransport of
tcp -> {<<"http">>, gun_tcp};
tls -> {<<"https">>, gun_tls}
tls -> {<<"https">>, gun_tls};
quic -> {<<"https">>, gun_quicer}
end,
OwnerRef = monitor(process, Owner),
{EvHandler, EvHandlerState0} = maps:get(event_handler, Opts,
Expand Down Expand Up @@ -1061,6 +1077,38 @@ domain_lookup({call, From}, {stream_info, _}, _) ->
domain_lookup(Type, Event, State) ->
handle_common(Type, Event, ?FUNCTION_NAME, State).

connecting(_, {retries, Retries, LookupInfo}, State=#state{opts=Opts,
transport=gun_quicer, event_handler=EvHandler, event_handler_state=EvHandlerState0}) ->
%% @todo We are doing the TLS handshake at the same time,
%% we cannot separate it from the connection. Fire events.
ConnectTimeout = maps:get(connect_timeout, Opts, infinity),
ConnectEvent = #{
lookup_info => LookupInfo,
timeout => ConnectTimeout
},
EvHandlerState1 = EvHandler:connect_start(ConnectEvent, EvHandlerState0),
case gun_quicer:connect(LookupInfo, ConnectTimeout) of
{ok, Socket} ->
%% @todo We should double check the ALPN result.
[Protocol] = maps:get(protocols, Opts, [http3]),
ProtocolName = case Protocol of
{P, _} -> P;
P -> P
end,
EvHandlerState = EvHandler:connect_end(ConnectEvent#{
socket => Socket,
protocol => ProtocolName
}, EvHandlerState1),
{next_state, connected_protocol_init,
State#state{event_handler_state=EvHandlerState},
{next_event, internal, {connected, Retries, Socket, Protocol}}};
{error, Reason} ->
EvHandlerState = EvHandler:connect_end(ConnectEvent#{
error => Reason
}, EvHandlerState1),
{next_state, not_connected, State#state{event_handler_state=EvHandlerState},
{next_event, internal, {retries, Retries, Reason}}}
end;
connecting(_, {retries, Retries, LookupInfo}, State=#state{opts=Opts,
transport=Transport, event_handler=EvHandler, event_handler_state=EvHandlerState0}) ->
ConnectTimeout = maps:get(connect_timeout, Opts, infinity),
Expand Down Expand Up @@ -1100,6 +1148,7 @@ connecting(_, {retries, Retries, LookupInfo}, State=#state{opts=Opts,
initial_tls_handshake(_, {retries, Retries, Socket}, State0=#state{opts=Opts, origin_host=OriginHost}) ->
Protocols = maps:get(protocols, Opts, [http2, http]),
HandshakeEvent = #{
%% @todo This results in ensure_tls_opts being called twice.
tls_opts => ensure_tls_opts(Protocols, maps:get(tls_opts, Opts, []), OriginHost),
timeout => maps:get(tls_handshake_timeout, Opts, infinity)
},
Expand Down Expand Up @@ -1453,13 +1502,22 @@ handle_common_connected(Type, Event, StateName, StateData) ->
handle_common_connected_no_input(Type, Event, StateName, StateData).

%% Socket events.
handle_common_connected_no_input(info, Msg, _, State=#state{
protocol=Protocol=gun_http3, protocol_state=ProtoState, cookie_store=CookieStore0,
event_handler=EvHandler, event_handler_state=EvHandlerState0})
when element(1, Msg) =:= quic ->
% ct:pal("~p", [Msg]),
{Commands, CookieStore, EvHandlerState} = Protocol:handle(Msg,
ProtoState, CookieStore0, EvHandler, EvHandlerState0),
maybe_active(commands(Commands, State#state{cookie_store=CookieStore,
event_handler_state=EvHandlerState}));
handle_common_connected_no_input(info, {OK, Socket, Data}, _,
State0=#state{socket=Socket, messages={OK, _, _},
State=#state{socket=Socket, messages={OK, _, _},
protocol=Protocol, protocol_state=ProtoState, cookie_store=CookieStore0,
event_handler=EvHandler, event_handler_state=EvHandlerState0}) ->
{Commands, CookieStore, EvHandlerState} = Protocol:handle(Data,
ProtoState, CookieStore0, EvHandler, EvHandlerState0),
maybe_active(commands(Commands, State0#state{cookie_store=CookieStore,
maybe_active(commands(Commands, State#state{cookie_store=CookieStore,
event_handler_state=EvHandlerState}));
handle_common_connected_no_input(info, {Closed, Socket}, _,
State=#state{socket=Socket, messages={_, Closed, _}}) ->
Expand Down Expand Up @@ -1575,6 +1633,8 @@ maybe_active(Other) ->

active(State=#state{active=false}) ->
{ok, State};
active(State=#state{transport=gun_quicer}) ->
{ok, State};
active(State=#state{socket=Socket, transport=Transport}) ->
case Transport:setopts(Socket, [{active, once}]) of
ok ->
Expand Down
2 changes: 1 addition & 1 deletion src/gun_http.erl
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
socket :: inet:socket() | ssl:sslsocket(),
transport :: module(),
opts = #{} :: gun:http_opts(),
version = 'HTTP/1.1' :: cow_http:version(),
version = 'HTTP/1.1' :: cow_http1:version(),
connection = keepalive :: keepalive | close,
buffer = <<>> :: binary(),

Expand Down
4 changes: 3 additions & 1 deletion src/gun_http2.erl
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
%% by the client or by the server through PUSH_PROMISE frames.
%%
%% Streams can be found by ID or by Ref. The most common should be
%% the idea, that's why the main map has the ID as key. Then we also
%% the ID, that's why the main map has the ID as key. Then we also
%% have a Ref->ID index for faster lookup when we only have the Ref.
streams = #{} :: #{cow_http2:streamid() => #stream{}},
stream_refs = #{} :: #{reference() => cow_http2:streamid()},
Expand Down Expand Up @@ -1074,6 +1074,8 @@ prepare_headers(State=#http2_state{transport=Transport},
end,
%% @todo We also must remove any header found in the connection header.
%% @todo Much of this is duplicated in cow_http2_machine; sort things out.
%% I think we want to do this before triggering events, not when
%% building HeaderBlock.
Headers1 =
lists:keydelete(<<"host">>, 1,
lists:keydelete(<<"connection">>, 1,
Expand Down
Loading

0 comments on commit 6dd58a4

Please sign in to comment.