Skip to content

Commit ae34298

Browse files
committed
Reintroduce h2 work originally done in #286
1 parent 2363ff4 commit ae34298

29 files changed

+1719
-1694
lines changed

CHANGELOG.md

+27
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
## 1.3.0 (TBD)
2+
3+
### Enhancements
4+
5+
* Complete refactor of HTTP/2. Improved process model is MUCH easier to
6+
understand and yields about a 10% performance boost to HTTP/2 requests (#286)
7+
8+
### Changes
9+
10+
* **BREAKING CHANGE** The HTTP/2 header size limit options have been deprecated,
11+
and have been replaced with a single `max_header_block_size` option. The setting
12+
defaults to 50k bytes, and refers to the size of the compressed header block
13+
as sent on the wire (including any continuation frames)
14+
* We no longer log if processes that are linked to an HTTP/2 stream process
15+
terminate unexpectedly. This has always been unspecified behaviour so is not
16+
considered a breaking change
17+
* Calls of `Plug.Conn` functions for an HTTP/2 connection must now come from the
18+
stream process; any other process will raise an error. Again, this has always
19+
been unspecified behaviour
20+
* Reading the body of an HTTP/2 request after it has already been read will
21+
return `{:ok, ""}` instead of raising a `Bandit.BodyAlreadyReadError` as it
22+
previously did
23+
* We now send RST_STREAM frames if we complete a stream and the remote end is
24+
still open. This optimizes cases where the client may still be sending a body
25+
that we never consumed and don't care about
26+
* We no longer explicitly close the connection when we receive a GOAWAY frame
27+
128
## 1.2.2 (16 Feb 2024)
229

330
### Changes

lib/bandit.ex

+5-10
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,9 @@ defmodule Bandit do
117117
Options to configure the HTTP/2 stack in Bandit
118118
119119
* `enabled`: Whether or not to serve HTTP/2 requests. Defaults to true
120-
* `max_header_key_length`: The maximum permitted length of any single header key
121-
(expressed as the number of decompressed bytes) in an HTTP/2 request. Defaults to 10_000 bytes
122-
* `max_header_value_length`: The maximum permitted length of any single header value
123-
(expressed as the number of decompressed bytes) in an HTTP/2 request. Defaults to 10_000 bytes
124-
* `max_header_count`: The maximum permitted number of headers in an HTTP/2 request.
125-
Defaults to 50 headers
120+
* `max_header_block_size`: The maximum permitted length of a field block of an HTTP/2 request
121+
(expressed as the number of compressed bytes). Includes any concatenated block fragments from
122+
continuation frames. Defaults to 50_000 bytes
126123
* `max_requests`: The maximum number of requests to serve in a single
127124
HTTP/2 connection before closing the connection. Defaults to 0 (no limit)
128125
* `default_local_settings`: Options to override the default values for local HTTP/2
@@ -135,9 +132,7 @@ defmodule Bandit do
135132
"""
136133
@type http_2_options :: [
137134
enabled: boolean(),
138-
max_header_key_length: pos_integer(),
139-
max_header_value_length: pos_integer(),
140-
max_header_count: pos_integer(),
135+
max_header_block_size: pos_integer(),
141136
max_requests: pos_integer(),
142137
default_local_settings: Bandit.HTTP2.Settings.t(),
143138
compress: boolean(),
@@ -192,7 +187,7 @@ defmodule Bandit do
192187

193188
@top_level_keys ~w(plug scheme port ip keyfile certfile otp_app cipher_suite display_plug startup_log thousand_island_options http_1_options http_2_options websocket_options)a
194189
@http_1_keys ~w(enabled max_request_line_length max_header_length max_header_count max_requests log_unknown_messages compress deflate_options)a
195-
@http_2_keys ~w(enabled max_header_key_length max_header_value_length max_header_count max_requests default_local_settings compress deflate_options)a
190+
@http_2_keys ~w(enabled max_header_block_size max_requests default_local_settings compress deflate_options)a
196191
@websocket_keys ~w(enabled max_frame_size validate_text_frames compress)a
197192
@thousand_island_keys ThousandIsland.ServerConfig.__struct__()
198193
|> Map.from_struct()

lib/bandit/http2/README.md

+37-31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# HTTP/2 Handler
22

33
Included in this folder is a complete `ThousandIsland.Handler` based implementation of HTTP/2 as
4-
defined in [RFC 9113](https://datatracker.ietf.org/doc/rfc9113).
4+
defined in [RFC 9110](https://datatracker.ietf.org/doc/rfc9110) & [RFC
5+
9113](https://datatracker.ietf.org/doc/rfc9113)
56

67
## Process model
78

@@ -10,24 +11,31 @@ Within a Bandit server, an HTTP/2 connection is modeled as a set of processes:
1011
* 1 process per connection, a `Bandit.HTTP2.Handler` module implementing the
1112
`ThousandIsland.Handler` behaviour, and;
1213
* 1 process per stream (i.e.: per HTTP request) within the connection, implemented as
13-
a `Bandit.HTTP2.StreamTask` Task
14+
a `Bandit.HTTP2.StreamProcess` process
15+
16+
Each of these processes model the majority of their state via a
17+
`Bandit.HTTP2.Connection` & `Bandit.HTTP2.Stream` struct, respectively.
1418

1519
The lifetimes of these processes correspond to their role; a connection process lives for as long
1620
as a client is connected, and a stream process lives only as long as is required to process
17-
a single stream request within a connection.
21+
a single stream request within a connection.
1822

1923
Connection processes are the 'root' of each connection's process group, and are supervised by
2024
Thousand Island in the same manner that `ThousandIsland.Handler` processes are usually supervised
2125
(see the [project README](https://github.com/mtrudel/thousand_island) for details).
2226

23-
Stream processes are not supervised by design. The connection process starts new stream processes as required, and does so
24-
once a complete header block for a new stream has been received. It starts stream processes via
25-
a standard `start_link` call, and manages the termination of the resultant linked stream processes
26-
by handling `{:EXIT,...}` messages as described in the Elixir documentation. This approach is
27-
aligned with the realities of the HTTP/2 model, insofar as if a connection process terminates
28-
there is no reason to keep its constituent stream processes around, and if a stream process dies
29-
the connection should be able to handle this without itself terminating. It also means that our
30-
process model is very lightweight - there is no extra supervision overhead present because no such
27+
Stream processes are not supervised by design. The connection process starts new
28+
stream processes as required, via a standard `start_link`
29+
call, and manages the termination of the resultant linked stream processes by
30+
handling `{:EXIT,...}` messages as described in the Elixir documentation. Each
31+
stream process stays alive long enough to fully model an HTTP/2 stream,
32+
beginning its life in the `:init` state and ending it in the `:closed` state (or
33+
else by a stream or connection error being raised). This approach is aligned
34+
with the realities of the HTTP/2 model, insofar as if a connection process
35+
terminates there is no reason to keep its constituent stream processes around,
36+
and if a stream process dies the connection should be able to handle this
37+
without itself terminating. It also means that our process model is very
38+
lightweight - there is no extra supervision overhead present because no such
3139
supervision is required for the system to function in the desired way.
3240

3341
## Reading client data
@@ -40,13 +48,15 @@ looks like the following:
4048
2. Frames are parsed from these bytes by calling the `Bandit.HTTP2.Frame.deserialize/2`
4149
function. If successful, the parsed frame(s) are returned. We retain any unparsed bytes in
4250
a buffer in order to attempt parsing them upon receipt of subsequent data from the client
43-
3. Parsed frames are passed into the `Bandit.HTTP2.Connection` module along with a struct of
44-
same module. Frames are applied against this struct in a vaguely FSM-like manner, using pattern
45-
matching within the `Bandit.HTTP2.Connection.handle_frame/3` function. Any side-effects of
46-
received frames are applied in these functions, and an updated connection struct is returned to
47-
represent the updated connection state. These side-effects can take the form of starting stream
48-
tasks, conveying data to running stream tasks, responding to the client with various frames, or
49-
any number of other actions
51+
3. Parsed frames are passed into the `Bandit.HTTP2.Connection` module along with a struct of
52+
same module. Frames are processed via the `Bandit.HTTP2.Connection.handle_frame/3` function.
53+
Connection-level frames are handled within the `Bandit.HTTP2.Connection`
54+
struct, and stream-level frames are passed along to the corresponding stream
55+
process, which is wholly responsible for managing all aspects of a stream's
56+
state (which is tracked via the `Bandit.HTTP2.Stream` struct). The one
57+
exception to this is the handling of frames sent to streams which have
58+
already been closed (and whose corresponding processes have thus terminated).
59+
Any such frames are discarded without effect.
5060
4. This process is repeated every time we receive data from the client until the
5161
`Bandit.HTTP2.Connection` module indicates that the connection should be closed, either
5262
normally or due to error. Note that frame deserialization may end up returning a connection
@@ -58,19 +68,15 @@ looks like the following:
5868

5969
## Processing requests
6070

61-
The details of a particular stream are contained within a `Bandit.HTTP2.Stream` struct
62-
(as well as a `Bandit.HTTP2.StreamTask` process in the case of active streams). The
63-
`Bandit.HTTP2.StreamCollection` module manages a collection of streams, allowing for the memory
64-
efficient management of complete & yet unborn streams alongside active ones.
65-
66-
Once a complete header block has been read, a `Bandit.HTTP2.StreamTask` is started to manage the
67-
actual calling of the configured `Plug` module for this server, using the `Bandit.HTTP2.Adapter`
68-
module as the implementation of the `Plug.Conn.Adapter` behaviour. This adapter uses a simple
69-
`receive` pattern to listen for messages sent to it from the connection process, a pattern chosen
70-
because it allows for easy provision of the blocking-style API required by the `Plug.Conn.Adapter`
71-
behaviour. Functions in the `Bandit.HTTP2.Adapter` behaviour which write data to the client use
72-
`GenServer` calls to the `Bandit.HTTP2.Handler` module in order to pass data to the connection
73-
process.
71+
The state of a particular stream are contained within a `Bandit.HTTP2.Stream`
72+
struct, maintained within a `Bandit.HTTP2.StreamProcess` process. As part of the
73+
stream's lifecycle, the server's configured Plug is called, with an instance of
74+
the `Bandit.HTTP2.Adapter` struct being used to interface with the Plug. There
75+
is a separation of concerns between the aspect of HTTP semantics managed by
76+
`Bandit.HTTP2.Adapter` (roughly, those concerns laid out in
77+
[RFC9110](https://datatracker.ietf.org/doc/html/rfc9110)) and the more
78+
transport-specific HTTP/2 concerns managed by `Bandit.HTTP2.Stream` (roughly the
79+
concerns specified in [RFC9113](https://datatracker.ietf.org/doc/html/rfc9113)).
7480

7581
# Testing
7682

0 commit comments

Comments
 (0)