This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

BIN
phoenix/deps/bandit/.hex Normal file

Binary file not shown.

View File

@@ -0,0 +1,839 @@
## 1.10.4 (25 Mar 2026)
### Enhancements
* Support `{:shutdown, :disconnected}` as a normal WebSocket result code (#576, thanks @wwitek-whatnot!)
## 1.10.3 (22 Feb 2026)
### Enhancements
* Support authority form requests for CONNECT requests (#571)
* Narrow acceptance of asterisk form requests to OPTIONS requests (#571)
* Detect client disconnect on timeout in ensure_completed (#566, thanks @pepicrft!)
* Improve http2 sendfile streaming (#565, thanks @elibosley!)
## 1.10.2 (22 Jan 2026)
### Enhancements
* Distinguish client disconnects from genuine body read timeouts (#564, thanks @pepicrft!)
## 1.10.1 (5 Jan 2026)
### Changes
* Change default preference order for compression methods to be 'zstd (if present), gzip, deflate' (#562)
### Fixes
* Allow `:zstd_options` key to be set in config (#558, thanks @Fudoshiki!)
* Fix error where deflate responses weren't always completely sent (#559, thanks @josevalim!)
## 1.10.0 (29 Dec 2025)
### Enhancements
* Expose `response_encodings` to allow specifying an explicit preference order to compression encodings (#555)
## 1.9.0 (12 Dec 2025)
### Enhancements
* Skip body draining when Connection: close is set (#546, thanks @pepicrft!)
* Make deflate options for WebSockets configurable (#540, thanks @proxima!)
* Mitigate HTTP/2 rapid reset attacks (#533, thanks @NelsonVides!)
* Implement improved respect for SETTINGS_MAX_CONCURRENT_STREAMS (#524, thanks @NelsonVides!)
* Support zstd HTTP compression (#514, thanks @mattmatters!)
## 1.8.0 (18 Aug 2025)
### Enhancements
* If the user has set a `content-length` header when calling `send_chunked/3`,
the response is streamed via content-length delimited framing and not chunked (#510)
## 1.7.0 (29 May 2025)
### Enhancements
* Add support for new `get_sock_data/1` and `get_ssl_data/1` callbacks from Plug 1.18 (#497)
* Honour server-sent `Connection: close` headers (#495, thanks @ruslandoga!)
### Fixes
* Don't overwrite non-default HTTP/2 settings when receiving HTTP/2 settings (#494, thanks @ns-blee!)
* Fix handling of early-connection error handling in HTTP/2 (#486)
## 1.6.11 (31 Mar 2025)
### Changes
* Ensure that HTTP/1 request headers are sent to the Plug in the order they're
sent (#482)
* Do not populate the `cookies` header with an empty string if no cookies were
sent in HTTP/2 (#483)
## 1.6.10 (25 Mar 2025)
### Fixes
* Fix bug introduced when closing compressed websock connections in certain circumstances (#478)
### Enhancements
* Standardize & document the format of messages sent to HTTP/2 Stream processes (#481)
## 1.6.9 (21 Mar 2025)
### Fixes
* Do not close compression context before calling websock close callback (#462,
thanks @thiagopromano!)
## 1.6.8 (5 Mar 2025)
### Fixes
* Do not send stream WINDOW_UPDATEs on the last data frame of a stream
### Enhancements
* Add `status` to the telemetry metadata emitted on WebSocket upgrades (#466)
## 1.6.7 (30 Jan 2025)
### Changes
* Consider timeouts when reading HTTP/1 headers as a connection error and not an HTTP error
* Enhance logging for WebSocket deflation errors
## 1.6.6 (25 Jan 2025)
### Fixes
* Consider closures during HTTP/1 header reading as a socket error to silence them by default via `log_client_closures` config flag
* Send `connection: close` when closing connection on error per RFC9112§9.6
### Enhancements
* Add experimental opt-in trace logging to help diagnose hard to reproduce errors
* Move CI to 1.18 & improve tests (#459, #461, thanks @grzuy!)
## 1.6.5 (15 Jan 2025)
### Fixes
* Fix regression introduced in 1.6.1 where we would not send headers set by the Plug during WebSocket upgrades (#458)
### Enhancements
* Properly normalize Erlang errors before emitting telemetry and logged crash_reason (#455, thanks @grzuy!)
## 1.6.4 (11 Jan 2025)
### Fixes
* Fix error in socket setup error handling introduced in 1.6.2 (thanks @danielspofford!)
## 1.6.3 (8 Jan 2025)
### Fixes
* Always close HTTP/1 connection in any case where an error comes out of the plug (#452, thanks @zookzook!)
* Fix dialyzer warning introduced by Thousand Island 1.3.9
## 1.6.2 (4 Jan 2025)
### Enhancements
* Send telemetry events on Plugs that throw or exit (#443)
* Improve test robustness & speed (#446)
* Read a minimal number of bytes when sniffing for protocol (#449)
* Add `plug` and `websock` to logging metadata whenever possible (#448)
* Add `plug` and `websock` to telemetry metadata whenever possible (#447)
* Silently eat Bandit.TransportError errors during HTTP/1 error fallback handling
### Fixes
* Bump hpax to 1.0.2, fixes https://github.com/phoenixframework/phoenix/issues/6020 (thanks @krainboltgreene!)
* Fix cases where we would desync on pipelined POST requests (#442)
### Changes
* Unwrap Plug.Conn.WrapperErrors raised by Plug and handle the wrapped error per policy
* Surface socket setup errors as Bandit.TransportError for consistency in logging
## 1.6.1 (6 Dec 2024)
### Enhancements
* Add deflate support when sending chunked responses (#429)
### Fixes
* Bring in updated HPAX to fix HTTP/2 error cases seen in AWS load balancing
environments (#392)
* Improve handle of pipelined HTTP/1.1 requests (#437)
* Improve error handling when dealing with socket errors (#433)
### Changes
* Use `Plug.Call.inform/2` to send websocket upgrades (#428)
## 1.6.0 (18 Nov 2024)
### Enhancements
* Add framework for supporting optimized native code on various hot paths (#394,
thanks @alisinabh!)
* Pass conn and exception data as logger metadata (#417 & #420, thanks @grzuy!)
* Loosen hpax dependency requirements
* Add `log_client_closures` http option, defaulting to false (#397, thanks @goncalotomas!)
* Handle plugs that throw a result (#411, thanks @grzuy!)
### Fixes
* Improve content-length send logic per RFC9110§8.6/8.7
* Explicitly signal keepalives in HTTP/1.0 requests
### Changes
* Fix typo & clarify docs
* Update security policy
## 1.5.7 (1 Aug 2024)
### Changes
* Timeouts encountered while reading a request body will now result in a `408
Request Timeout` being returned to the client by way of a `Bandit.HTTPError`
being raised. Previously, a `:more` tuple was returned (#385, thanks
@martosaur!)
## 1.5.6 (1 Aug 2024)
### Fixes
* Improve handling of the end of stream condition for HTTP/2 requests that send
a body which isn't read by the Plug (#387, thanks @fekle!)
## 1.5.5 (19 Jun 2024)
### Changes
* Add `domain: [:bandit]` to the metadata of all logger calls
* Bring logging of early-connect HTTP2 errors under the `log_protocol_errors` umbrella
## 1.5.4 (14 Jun 2024)
### Changes
* Raise HTTP/2 send window timeouts as stream errors so that they're logged as
protocol errors (thanks @hunterboerner!)
## 1.5.3 (7 Jun 2024)
### Changes
* Add `:short` and `:verbose` options to `log_protocol_errors` configuration
option. **Change default value to `:short`, which will log protocol
errors as a single summary line instead of a full stack trace**
* Raise `Bandit.HTTPError` errors when attempting to write to a closed client
connection (except for chunk/2 calls, which now return `{:error, reason}`).
Unless otherwise caught by the user, these errors will bubble out past the
configured plug and terminate the plug process. This closely mimics the
behaviour of Cowboy in this regard (#359)
* Respect the plug-provided content-length on HEAD responses (#353, thanks
@meeq!)
* Minor changes to how 'non-system process dictionary entries' are identified
### Fixes
* No longer closes on HTTP/1 requests smaller than the size of the HTTP/2
preamble
* Close deflate contexts more eagerly for reduced memory use
## 1.5.2 (10 May 2024)
### Fixes
* Don't crash on non-stringable process dictionary keys (#350, thanks
@ryanwinchester, @chrismccord!)
## 1.5.1 (10 May 2024)
### Enhancements
* Process dictionary is now cleared of all non-system process dictionary entries
between keepalive requests (#349)
* Explicitly run a GC before upgrading a connection to websocket (#348)
* Improve docs around deflate options (thanks @kotsius!)
## 1.5.0 (21 Apr 2024)
### Enhancements
* Bandit now respects an exception's conformance to `Plug.Exception` when
determining which status code to return to the client (if the plug did not
already send one). Previously they were always returned as 500 (for HTTP/1)
or an 'internal error' stream error (for HTTP/2)
* Bandit now only logs the stacktrace of plug-generated exceptions whose status
code (as determined by `Plug.Exception.status/1`) is contained within the new
`log_exceptions_with_status_codes` configuration option (defaulting to
`500..599`)
* As a corollary to the above, Bandit request handler processes no longer exit
abnormally in the case of plug-generated exceptions
### Changes
* HTTP semantic errors encountered in an HTTP/2 request are returned to the
client using their proper status code instead of as a 'protocol error' stream
error
## 1.4.2 (2 Apr 2024)
### Enhancements
* Support top-level :inet and :inet6 options for Plug.Cowboy compatibility (#337)
## 1.4.1 (27 Mar 2024)
### Changes
* **BREAKING CHANGE** Move `log_protocol_errors` configuration option into
shared `http_options` top-level config (and apply it to HTTP/2 errors as well)
* **BREAKING CHANGE** Remove `origin_telemetry_span_context` from WebSocket
telemetry events
* **BREAKING CHANGE** Remove `stream_id` from HTTP/2 telemetry events
* Add `conn` to the metadata of telemetry start events for HTTP requests
* Stop sending WebSocket upgrade failure reasons to the client (they're still
logged)
### Fixes
* Return HTTP semantic errors to HTTP/2 clients as protocol errors instead of
internal errors
## 1.4.0 (26 Mar 2024)
> [!WARNING]
> **IMPORTANT** Phoenix users MUST upgrade to WebSockAdapter `0.5.6` or newer when
> upgrading to Bandit `1.4.0` or newer as some internal module names have changed
### Enhancements
* Complete refactor of HTTP/2. Improved process model is MUCH easier to
understand and yields about a 10% performance boost to HTTP/2 requests (#286 /
#307)
* Substantial refactor of the HTTP/1 and HTTP/2 stacks to share a common code
path for much of their implementations, with the protocol-specific parts being
factored out to a minimal `Bandit.HTTPTransport` protocol internally, which
allows each protocol to define its own implementation for the minimal set of
things that are different between the two stacks (#297 / #329)
### Changes
* **BREAKING CHANGE** Move configuration options that are common between HTTP/1
and HTTP/2 stacks into a shared `http_options` top-level config
* **BREAKING CHANGE** The HTTP/2 header size limit options have been deprecated,
and have been replaced with a single `max_header_block_size` option. The setting
defaults to 50k bytes, and refers to the size of the compressed header block
as sent on the wire (including any continuation frames)
* **BREAKING CHANGE** Remove `req_line_bytes`, `req_header_bytes`, `resp_line_bytes` and
`resp_header_bytes` from HTTP/1 request telemetry measurements
* **BREAKING CHANGE** Remove `status`, `method` and `request_target` from
telemetry metadata. All of this information can be obtained from the `conn`
struct attached to most telemetry events
* **BREAKING CHANGE** Re-reading a body that has already been read returns `{:ok,
"", conn}` instead of raising a `Bandit.BodyAlreadyReadError`
* **BREAKING CHANGE** Remove `Bandit.BodyAlreadyReadError`
* **BREAKING CHANGE** Remove h2c support via Upgrade header. This was deprecated
in RFC9113 and never in widespread use. We continue to support h2c via prior
knowledge, which remains the only supported mechanism for h2c in RFC9113
* Treat trailing bytes beyond the indicated content-length on HTTP/1 requests as
an error
* Surface request body read timeouts on HTTP/1 requests as `{:more...}` tuples
and not errors
* Socket sending errors are no longer surfaced on chunk sends in HTTP/1
* We no longer log if processes that are linked to an HTTP/2 stream process
terminate unexpectedly. This has always been unspecified behaviour so is not
considered a breaking change
* Calls of `Plug.Conn` functions for an HTTP/2 connection must now come from the
stream process; any other process will raise an error. Again, this has always
been unspecified behaviour
* We now send an empty DATA frame for explicitly zero byte bodies instead of
optimizing to a HEADERS frame with end_stream set (we still do so for cases
such as 204/304 and HEAD requests)
* We now send RST_STREAM frames if we complete a stream and the remote end is
still open. This optimizes cases where the client may still be sending a body
that we never consumed and don't care about
* We no longer explicitly close the connection when we receive a GOAWAY frame
## 1.3.0 (8 Mar 2024)
### Enhancements
* Run an explicit garbage collection between every 'n' keepalive requests on the same HTTP/1.1 connection in order to keep reported (but not actual!) memory usage from growing over time. Add `gc_every_n_keepalive_requests` option to configure this (default value of
`5`). #322, thanks @ianko & @Nilsonn!)
* Add `log_protocol_errors` option to optionally quell console logging of 4xx errors generated by Bandit. Defaults to `true` for now; may switch to `false` in the future based on adoption (#321, thanks @Stroemgren!)
### Changes
* Don't send a `transfer-encoding` header for 1xx or 204 responses (#317, thanks
@mwhitworth!)
## 1.2.3 (23 Feb 2024)
### Changes
* Log port number when listen fails (#312, thanks @jonatanklosko!)
* Accept mixed-case keepalive directives (#308, thanks @gregors!)
## 1.2.2 (16 Feb 2024)
### Changes
* Reset Logger metadata on every request
## 1.2.1 (12 Feb 2024)
### Changes
* Disable logging of unknown messages received by an idle HTTP/1 handler to
avoid noise on long polling clients. This can be changed via the
`log_unknown_messages` http_1 option (#299)
## 1.2.0 (31 Jan 2024)
### Enhancements
* Automatically pull in `:otp_app` value in Bandit.PhoenixAdapter (thanks
@krns!)
* Include response body metrics for HTTP/1 chunk responses
### Fixes
* Fix broken HTTP/1 inform/3 return value (thanks @wojtekmach!)
* Maintain HTTP/1 read timeout after receiving unknown messages
## 1.1.3 (12 Jan 2024)
### Fixes
* Do not send a fallback response if the plug has already sent one (#288 & #289, thanks @jclem!)
### Changes
* Packagaing improvements (#283, thanks @wojtekmach!)
## 1.1.2 (20 Dec 2023)
### Fixes
* Fix support for proplist-style arguments (#277, thanks @jjcarstens!)
* Speed up WebSocket framing (#272, thanks @crertel!)
* Fix off-by-one error in HTTP2 sendfile (#269, thanks @OrangeDrangon!)
* Improve mix file packaging (#266, thanks @patrickjaberg!)
## 1.1.1 (14 Nov 2023)
### Fixes
* Do not advertise disabled protocols via ALPN (#263)
## 1.1.0 (2 Nov 2023)
### Changes
* Messages sent to Bandit HTTP/1 handlers no longer intentionally crash the
handler process but are now logged in the same manner as messages sent to a
no-op GenServer (#259)
* Messages regarding normal termination of monitored processes are no longer
handled by the WebSocket handler, but are now passed to the configured
`c:WebSock.handle_info/2` callback (#259)
### Enhancements
* Add support for `Phoenix.Endpoint.server_info/1` (now in Phoenix main; #258)
* Add support for `:max_heap_size` option in WebSocket handler (introduced in
websock_adapter 0.5.5; #255, thanks @v0idpwn!)
## 1.0.0 (18 Oct 2023)
### Changes
* Remove internal tracking of remote `max_concurrent_streams` setting (#248)
## 1.0.0-pre.18 (10 Oct 2023)
### Fixes
* Fix startup when plug module has not yet been loaded by the BEAM
## 1.0.0-pre.17 (9 Oct 2023)
### Enhancements
* Support function based plugs & improve startup analysis of plug configuration
(#236)
* Improve keepalive support when Plug does not read request bodies (#244)
* Improve logic around not sending bodies on HEAD requests (#242)
### Changes
* Internal refactor of WebSocket validation (#229)
## 1.0.0-pre.16 (18 Sep 2023)
### Changes
* Use protocol default port in the event that no port is provided in host header (#228)
### Fixes
* Improve handling of iolist response bodies (#231, thanks @travelmassive!)
## 1.0.0-pre.15 (9 Sep 2023)
### Fixes
* Fix issue with setting remote IP at connection startup (#227, thanks @jimc64!)
## 1.0.0-pre.14 (28 Aug 2023)
### Enhancements
* Add `Bandit.PhoenixAdapter.bandit_pid/2` (#212)
* Return errors to `Plug.Conn.Adapter.chunk/2` HTTP/1 calls (#216)
### Changes
* `Plug.Conn` function calls must come from the process on which `Plug.call/2` was called (#217, reverts #117)
## 1.0.0-pre.13 (15 Aug 2023)
### Enhancements
* Add ability to send preamble frames when closing a WebSock connection (#211)
## 1.0.0-pre.12 (12 Aug 2023)
## Fixes
* Bump ThousandIsland to 1.0.0-pre.7 to fix leaking file descriptors on
`Plug.Conn.sendfile/5` calls (thanks @Hermanverschooten!)
## 1.0.0-pre.11 (11 Aug 2023)
## Changes
* **BREAKING CHANGE** Move `conn` value in telemetry events from measurements to metadata
## Enhancements
* Add `method`, `request_target` and `status` fields to telemetry metadata on HTTP stop events
* Improve RFC compliance regarding cache-related headers on deflated responses (#207, thanks @tanguilp!)
* Bump to Thousand Island `1.0.0-pre.6`
* Doc improvements (particularly around implementation notes)
* Typespec improvements (thanks @moogle19!)
## 1.0.0-pre.10 (28 Jun 2023)
## Enhancements
* Add support for `Plug.Conn.inform/3` on HTTP/1 connections (#180)
* Add support for h2c upgrades (#186, thanks @alisinabh!)
* Internal refactoring of HTTP/1 content-length encoded body reads (#184, #190,
thanks @asakura & @moogle19!)
## Changes
* Bump Thousand Island to 1.0.0-pre.6 (gaining support for suspend/resume API)
* Drop Elixir 1.12 as a supported target (it should continue to work, but is no
longer covered by CI)
## Fixes
* Fix crash when Plug used `Plug.Conn.get_peer_data/1` function on HTTP/1
connections (#170, thanks @moogle19!)
* Fix port behaviour when connecting over unix socket (#176, thanks @asakura
& @ibarchenkov!)
## 1.0.0-pre.9 (16 Jun 2023)
## Changes
* Use new ThousandIsland APIs for socket info (#167, thanks @asakura!)
## Fixes
* Handle nil connection close reason when closing a WebSocket
## 1.0.0-pre.8 (15 Jun 2023)
## Fixes
* Further improve logging on WebSocket upgrade errors (#149)
## 1.0.0-pre.7 (14 Jun 2023)
## Enhancements
* Refactor HTTP/1 read routines (#158 & #166, thanks @asakura!)
* Improve logging on WebSocket upgrade errors (#149)
## Changes
* Override any content-length headers that may have been set by Plug (#165)
* Send content-length on HTTP/2 responses where appropriate (#165)
## Fixes
* Send correct content-length header when sending deflated response (#151)
* Do not attempt to deflate if Plug sends a content-encoding header (#165)
* Improve corner case handling of content-length request header (#163, thanks
@ryanwinchester!)
* Handle case where ThousandIsland returns error tuples on some helper routines
(#162)
## 1.0.0-pre.6 (8 Jun 2023)
### Changes
* Always use the declaed scheme if declared in a request-line or `:scheme`
pseudo-header (#159)
* Internal tidying (thanks @asakura!)
## 1.0.0-pre.5 (2 Jun 2023)
### Enhancements
* Total overhaul of typespecs throughout the library (thanks @asakura!)
## 1.0.0-pre.4 (23 May 2023)
### Enhancements
* Performance / correctness improvements to header length validation (#143,
thanks @moogle19!)
* Performance improvements to host header port parsing (#145 & #147, thanks
@ryanwinchester!)
* Improve WebSocket upgrade failure error messages to aid in diagnosis (#152)
### Changes
* Consolidate credo config (#146, thanks @ryanwinchester!)
### Fixes
* Fix error in suggested version dependencies during 1.0-pre series (#142,
thanks @cvkmohan!)
## 1.0.0-pre.3 (3 May 2023)
### Enhancements
* Respect read timeout for HTTP/1 keepalives (#140)
* Support Websock 0.5.1, including support for optional `c:WebSock.terminate/2`
(#131)
### Changes
* Use Req instead of Finch in tests (#137)
* Improve a few corner cases in tests (#136)
## 1.0.0-pre.2 (24 Apr 2023)
### Fixes
* Don't require transport_options to be a keyword list (#130, thanks @justinludwig!)
## 1.0.0-pre.1 (21 Apr 2023)
### Changes
* Update Thousand Island dependency to 1.0-pre
# Changelog for 0.7.x
## 0.7.7 (11 Apr 2023)
### Changes
* Bandit will now raise an error at startup if no plug is specified in config
(thanks @moogle19!)
### Fixes
* Fix crash at startup when using `otp_app` option (thanks @moogle19!)
* Minor doc formatting fixes
## 0.7.6 (9 Apr 2023)
### Changes
* **BREAKING CHANGE** Rename top-level `options` field to `thousand_island_options`
* **BREAKING CHANGE** Rename `deflate_opts` to `deflate_options` where used
* Massive overhaul of documentation to use types where possible
* Bandit now uses a term of the form `{Bandit, ref()}` for `id` in our child spec
* Bumped to Thousand Island 0.6.7. `num_connections` is now 16384 by default
### Enhancements
* Added top level support for the following convenience parameters:
* `port` can now be set at the top level of your configuration
* `ip` can now be set at the top level of your configuration
* `keyfile` and `certfile` can now be set at the top level of your configuration
* Transport options are now validated by `Plug.SSL.configure/1` when starting
an HTTPS server
* Rely on Thousand Island to validate options specified in `thousand_island_options`. This should avoid cases like #125 in the future.
## 0.7.5 (4 Apr 2023)
### Changes
* Drop explicit support for Elixir 1.11 since we no longer test it in CI (should
still work, just that it's now at-your-own-risk)
* Add logo to ex_doc and README
### Fixes
* Allow access to Thousand Island's underlying `shutdown_timeout` option
* Fix test errors that cropped up in OTP 26
## 0.7.4 (27 Mar 2023)
### Changes
* Calling `Plug.Conn` adapter functions for HTTP/2 based requests are no longer
restricted to being called from the process which called `c:Plug.call/2`
### Enhancements
* Added `startup_log` to control whether / how Bandit logs the bound host & port
at startup (Thanks @danschultzer)
* Improved logging when the configured port is in use at startup (Thanks
@danschultzer)
* Update to Thousand Island 0.6.5
## 0.7.3 (20 Mar 2023)
### Enhancements
* Added advanced `handler_module` configuration option to `options`
### Fixes
* Support returning `x-gzip` as negotiated `content-encoding` (previously would
negotiate a request for `x-gzip` as `gzip`)
## 0.7.2 (18 Mar 2023)
### Enhancements
* Added HTTP compression via 'Content-Encoding' negotiation, enabled by default.
Configuration is available; see [Bandit
docs](https://hexdocs.pm/bandit/Bandit.html#module-config-options) for details
### Changes
* Minor refactor of internal HTTP/2 plumbing. No user visible changes
## 0.7.1 (17 Mar 2023)
### Changes
* Update documentation & messaging to refer to RFC911x RFCs where appropriate
* Validate top-level config options at startup
* Revise Phoenix adapter to support new config options
* Doc updates
## 0.7.0 (17 Mar 2023)
### Enhancements
* Add configuration points for various parameters within the HTTP/1, HTTP/2 and
WebSocket stacks. See [Bandit
docs](https://hexdocs.pm/bandit/Bandit.html#module-config-options) for details
# Changelog for 0.6.x
## 0.6.11 (17 Mar 2023)
### Changes
* Modified telemetry event payloads to match the conventions espoused by
`:telemetry.span/3`
* Default shutdown timeout is now 15s (up from 5s)
### Enhancements
* Update to Thosuand Island 0.6.4 (from 0.6.2)
## 0.6.10 (10 Mar 2023)
### Enhancements
* Support explicit setting of WebSocket close codes & reasons as added in WebSock
0.5.0
## 0.6.9 (20 Feb 2023)
### Enhancements
* Add comprehensive Telemetry support within Bandit, as documented in the
`Bandit.Telemetry` module
* Update our ThousandIsland dependnecy to pull in Thousand Island's newly
updated Telemetry support as documented in the `ThousandIsland.Telemetry`
module
* Fix parsing of host / request headers which contain IPv6 addresses (#97).
Thanks @derekkraan!
# Changes
* Use Plug's list of response code reason phrases (#96). Thanks @jclem!
* Minor doc updates
## 0.6.8 (31 Jan 2023)
### Changes
* Close WebSocket connections with a code of 1000 (instead of 1001) when
shutting down the server (#89)
* Use 100 acceptor processes by default (instead of 10)
* Improvements to make WebSocket frame masking faster
## 0.6.7 (17 Jan 2023)
### Enhancements
* Remove logging entirely when client connections do not contain a valid protocol
* Refactor WebSocket support for about a 20% performance lift
### Bug Fixes
* Add `nodelay` option to test suite to fix artificially slow WebSocket perf tests
## 0.6.6 (11 Jan 2023)
### Enhancements
* Log useful message when a TLS connection is made to plaintext server (#74)
## 0.6.5 (10 Jan 2023)
### Enhancements
* Update Thousand Island to 0.5.15 (quiets logging in timeout cases)
* Quiet logging in when client connections do not contain a valid protocol
* Refactor HTTP/1 for about a 20% performance lift
* Add WebSocket support to CI benchmark workflow
* Doc updates
### Bug Fixes
* Allow multiple instances of Bandit to be started in the same node (#75)
* Improve error handling in HTTP/1 when protocol errors are encountered (#74)

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Mat Trudel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,245 @@
![Bandit](https://github.com/mtrudel/bandit/raw/main/assets/readme_logo.png#gh-light-mode-only)
![Bandit](https://github.com/mtrudel/bandit/raw/main/assets/readme_logo-darkmode.png#gh-dark-mode-only)
[![Build Status](https://github.com/mtrudel/bandit/workflows/Elixir%20CI/badge.svg)](https://github.com/mtrudel/bandit/actions)
[![Docs](https://img.shields.io/badge/api-docs-green.svg?style=flat)](https://hexdocs.pm/bandit)
[![Hex.pm](https://img.shields.io/hexpm/v/bandit.svg?style=flat&color=blue)](https://hex.pm/packages/bandit)
Bandit is an HTTP server for Plug and WebSock apps.
Bandit is written entirely in Elixir and is built atop [Thousand
Island](https://github.com/mtrudel/thousand_island). It can serve HTTP/1.x,
HTTP/2 and WebSocket clients over both HTTP and HTTPS. It is written with
correctness, clarity & performance as fundamental goals. It is the default HTTP
server for [Phoenix](https://github.com/phoenixframework/phoenix) since release 1.7.11 of the framework.
In [ongoing automated performance
tests](https://github.com/mtrudel/bandit/actions/workflows/manual_benchmark.yml),
Bandit's HTTP/1.x engine is up to 4x faster than Cowboy depending on the number of concurrent
requests. When comparing HTTP/2 performance, Bandit is up to 1.5x faster than Cowboy. This is
possible because Bandit has been built from the ground up for use with Plug applications; this
focus pays dividends in both performance and also in the approachability of the code base.
Bandit also emphasizes correctness. Its HTTP/2 implementation scores 100% on the
[h2spec](https://github.com/summerwind/h2spec) suite in strict mode, and its
WebSocket implementation scores 100% on the
[Autobahn](https://github.com/crossbario/autobahn-testsuite) test suite, both of
which run as part of Bandit's comprehensive CI suite. Extensive unit test,
credo, dialyzer, and performance regression test coverage round out a test suite
that ensures that Bandit is and will remain a platform you can count on.
Lastly, Bandit exists to demystify the lower layers of infrastructure code. In a world where
The New Thing is nearly always adding abstraction on top of abstraction, it's important to have
foundational work that is approachable & understandable by users above it in the stack.
## Project Goals
* Implement comprehensive support for HTTP/1.0 through HTTP/2 & WebSockets (and
beyond) backed by obsessive RFC literacy and automated conformance testing
* Aim for minimal internal policy and HTTP-level configuration. Delegate to Plug & WebSock as much as
possible, and only interpret requests to the extent necessary to safely manage a connection
& fulfill the requirements of safely supporting protocol correctness
* Prioritize (in order): correctness, clarity, performance. Seek to remove the mystery of
infrastructure code by being approachable and easy to understand
* Along with our companion library [Thousand
Island](https://github.com/mtrudel/thousand_island), become the go-to HTTP
& low-level networking stack of choice for the Elixir community by being
reliable, efficient, and approachable
## Project Status
* Complete support for [Phoenix](https://github.com/phoenixframework/phoenix) applications (WebSocket
support requires Phoenix 1.7+)
* Complete support of the [Plug API](https://github.com/elixir-plug/plug)
* Complete support of the [WebSock API](https://github.com/phoenixframework/websock)
* Complete server support for HTTP/1.x as defined in [RFC
9112](https://datatracker.ietf.org/doc/html/rfc9112) & [RFC
9110](https://datatracker.ietf.org/doc/html/rfc9110)
* Complete server support for HTTP/2 as defined in [RFC
9113](https://datatracker.ietf.org/doc/html/rfc9113) & [RFC
9110](https://datatracker.ietf.org/doc/html/rfc9110), comprehensively covered
by automated [h2spec](https://github.com/summerwind/h2spec) conformance testing
* Support for HTTP content encoding compression on both HTTP/1.x and HTTP/2.
gzip and deflate methods are supported per
[RFC9110§8.4.1.{2,3}](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4.1.2)
* Complete server support for WebSockets as defined in [RFC
6455](https://datatracker.ietf.org/doc/html/rfc6455), comprehensively covered by automated
[Autobahn](https://github.com/crossbario/autobahn-testsuite) conformance testing. Per-message
compression as defined in [RFC 7692](https://datatracker.ietf.org/doc/html/rfc7692) is also
supported
* Extremely scalable and performant client handling at a rate up to 4x that of Cowboy for the same
workload with as-good-or-better memory use
Any Phoenix or Plug app should work with Bandit as a drop-in replacement for
Cowboy; exceptions to this are errors (if you find one, please [file an
issue!](https://github.com/mtrudel/bandit/issues)).
<!-- MDOC -->
## Using Bandit With Phoenix
Bandit fully supports Phoenix. Phoenix applications which use WebSockets for
features such as Channels or LiveView require Phoenix 1.7 or later.
Using Bandit to host your Phoenix application couldn't be simpler:
1. Add Bandit as a dependency in your Phoenix application's `mix.exs`:
```elixir
{:bandit, "~> 1.8"}
```
2. Add the following `adapter:` line to your endpoint configuration in `config/config.exs`, as in the following example:
```elixir
# config/config.exs
config :your_app, YourAppWeb.Endpoint,
adapter: Bandit.PhoenixAdapter, # <---- ADD THIS LINE
url: [host: "localhost"],
render_errors: ...
```
3. That's it! **You should now see messages at startup indicating that Phoenix is
using Bandit to serve your endpoint**, and everything should 'just work'. Note
that if you have set any exotic configuration options within your endpoint,
you may need to update that configuration to work with Bandit; see the
[Bandit.PhoenixAdapter](https://hexdocs.pm/bandit/Bandit.PhoenixAdapter.html)
documentation for more information.
## Using Bandit With Plug Applications
Using Bandit to host your own Plug is very straightforward. Assuming you have
a Plug module implemented already, you can host it within Bandit by adding
something similar to the following to your application's `Application.start/2`
function:
```elixir
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{Bandit, plug: MyApp.MyPlug}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
For less formal usage, you can also start Bandit using the same configuration
options via the `Bandit.start_link/1` function:
```elixir
# Start an http server on the default port 4000, serving MyApp.MyPlug
Bandit.start_link(plug: MyPlug)
```
## Configuration
A number of options are defined when starting a server. The complete list is
defined by the [`t:Bandit.options/0`](https://hexdocs.pm/bandit/Bandit.html#summary) type.
## Setting up an HTTPS Server
By far the most common stumbling block encountered when setting up an HTTPS
server involves configuring key and certificate data. Bandit is comparatively
easy to set up in this regard, with a working example looking similar to the
following:
```elixir
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{Bandit,
plug: MyApp.MyPlug,
scheme: :https,
certfile: "/absolute/path/to/cert.pem",
keyfile: "/absolute/path/to/key.pem"}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
## WebSocket Support
If you're using Bandit to run a Phoenix application as suggested above, there is
nothing more for you to do; WebSocket support will 'just work'.
If you wish to interact with WebSockets at a more fundamental level, the
[WebSock](https://hexdocs.pm/websock/WebSock.html) and
[WebSockAdapter](https://hexdocs.pm/websock_adapter/WebSockAdapter.html) libraries
provides a generic abstraction for WebSockets (very similar to how Plug is
a generic abstraction on top of HTTP). Bandit fully supports all aspects of
these libraries.
## Receiving messages in your Plug process: A word of warning
The Plug specification is concerned only with the shape of the `c:Plug.init/1`
and `c:Plug.call/2` functions; it says nothing about the process model that
underlies the call, nor about how the Plug function should respond to any
messages it may receive. Although it is occasionally necessary to receive
messages from within your Plug call, this must be done with caution as Bandit
makes extensive use of messaging internally, especially with HTTP/2 based
requests.
In particular, you must ensure that your code *never* receives messages that
match the patterns `{:bandit, _}` or `{:plug_conn, :sent}`. Any `receive` calls
you make should be appropriately guarded to ensure that these messages remain in
the process' mailbox for Bandit to process them when required.
<!-- MDOC -->
## Implementation Details
Bandit primarily consists of three protocol-specific implementations, one each
for [HTTP/1][], [HTTP/2][] and [WebSockets][]. Each of these implementations is
largely distinct from one another, and is described in its own README linked
above.
If you're just taking a casual look at Bandit or trying to understand how an
HTTP server works, the [HTTP/1][] implementation is likely the best place to
start exploring.
[HTTP/1]: lib/bandit/http1/README.md
[HTTP/2]: lib/bandit/http2/README.md
[WebSockets]: lib/bandit/websocket/README.md
## Contributing
Contributions to Bandit are very much welcome! Before undertaking any substantial work, please
open an issue on the project to discuss ideas and planned approaches so we can ensure we keep
progress moving in the same direction.
All contributors must agree and adhere to the project's [Code of
Conduct](https://github.com/mtrudel/bandit/blob/main/CODE_OF_CONDUCT.md).
Security disclosures should be handled per Bandit's published [security policy](https://github.com/mtrudel/bandit/blob/main/SECURITY.md).
## Installation
Bandit is [available in Hex](https://hex.pm/docs/publish). The package can be installed
by adding `bandit` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:bandit, "~> 1.8"}
]
end
```
Documentation can be found at [https://hexdocs.pm/bandit](https://hexdocs.pm/bandit).
# License
MIT

View File

@@ -0,0 +1,82 @@
{<<"links">>,
[{<<"Changelog">>,<<"https://hexdocs.pm/bandit/changelog.html">>},
{<<"GitHub">>,<<"https://github.com/mtrudel/bandit">>}]}.
{<<"name">>,<<"bandit">>}.
{<<"version">>,<<"1.10.4">>}.
{<<"description">>,
<<"A pure-Elixir HTTP server built for Plug & WebSock apps">>}.
{<<"elixir">>,<<"~> 1.13">>}.
{<<"app">>,<<"bandit">>}.
{<<"licenses">>,[<<"MIT">>]}.
{<<"files">>,
[<<"lib">>,<<"lib/bandit.ex">>,<<"lib/bandit">>,
<<"lib/bandit/primitive_ops">>,<<"lib/bandit/primitive_ops/websocket.ex">>,
<<"lib/bandit/phoenix_adapter.ex">>,<<"lib/bandit/http_transport.ex">>,
<<"lib/bandit/logger.ex">>,<<"lib/bandit/http1">>,
<<"lib/bandit/http1/README.md">>,<<"lib/bandit/http1/handler.ex">>,
<<"lib/bandit/http1/socket.ex">>,<<"lib/bandit/initial_handler.ex">>,
<<"lib/bandit/socket_helpers.ex">>,<<"lib/bandit/extractor.ex">>,
<<"lib/bandit/http2">>,<<"lib/bandit/http2/README.md">>,
<<"lib/bandit/http2/stream_collection.ex">>,<<"lib/bandit/http2/frame.ex">>,
<<"lib/bandit/http2/stream_process.ex">>,<<"lib/bandit/http2/settings.ex">>,
<<"lib/bandit/http2/connection.ex">>,<<"lib/bandit/http2/handler.ex">>,
<<"lib/bandit/http2/errors.ex">>,<<"lib/bandit/http2/stream.ex">>,
<<"lib/bandit/http2/frame">>,<<"lib/bandit/http2/frame/data.ex">>,
<<"lib/bandit/http2/frame/window_update.ex">>,
<<"lib/bandit/http2/frame/goaway.ex">>,
<<"lib/bandit/http2/frame/rst_stream.ex">>,
<<"lib/bandit/http2/frame/push_promise.ex">>,
<<"lib/bandit/http2/frame/settings.ex">>,
<<"lib/bandit/http2/frame/priority.ex">>,
<<"lib/bandit/http2/frame/unknown.ex">>,
<<"lib/bandit/http2/frame/ping.ex">>,
<<"lib/bandit/http2/frame/continuation.ex">>,
<<"lib/bandit/http2/frame/headers.ex">>,
<<"lib/bandit/http2/flow_control.ex">>,<<"lib/bandit/compression.ex">>,
<<"lib/bandit/application.ex">>,<<"lib/bandit/http_error.ex">>,
<<"lib/bandit/websocket">>,<<"lib/bandit/websocket/README.md">>,
<<"lib/bandit/websocket/frame.ex">>,
<<"lib/bandit/websocket/permessage_deflate.ex">>,
<<"lib/bandit/websocket/connection.ex">>,
<<"lib/bandit/websocket/handler.ex">>,
<<"lib/bandit/websocket/handshake.ex">>,
<<"lib/bandit/websocket/socket.ex">>,<<"lib/bandit/websocket/frame">>,
<<"lib/bandit/websocket/frame/connection_close.ex">>,
<<"lib/bandit/websocket/frame/pong.ex">>,
<<"lib/bandit/websocket/frame/binary.ex">>,
<<"lib/bandit/websocket/frame/ping.ex">>,
<<"lib/bandit/websocket/frame/text.ex">>,
<<"lib/bandit/websocket/frame/continuation.ex">>,
<<"lib/bandit/websocket/upgrade_validation.ex">>,
<<"lib/bandit/telemetry.ex">>,<<"lib/bandit/transport_error.ex">>,
<<"lib/bandit/pipeline.ex">>,<<"lib/bandit/adapter.ex">>,
<<"lib/bandit/delegating_handler.ex">>,<<"lib/bandit/headers.ex">>,
<<"lib/bandit/trace.ex">>,<<"lib/bandit/clock.ex">>,<<"mix.exs">>,
<<"README.md">>,<<"LICENSE">>,<<"CHANGELOG.md">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"thousand_island">>},
{<<"app">>,<<"thousand_island">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"plug">>},
{<<"app">>,<<"plug">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.18">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"websock">>},
{<<"app">>,<<"websock">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.5">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"hpax">>},
{<<"app">>,<<"hpax">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"telemetry">>},
{<<"app">>,<<"telemetry">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.4 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,436 @@
defmodule Bandit do
@external_resource Path.join([__DIR__, "../README.md"])
@moduledoc """
Bandit is an HTTP server for Plug and WebSock apps.
As an HTTP server, Bandit's primary goal is to act as 'glue' between client connections managed
by [Thousand Island](https://github.com/mtrudel/thousand_island) and application code defined
via the [Plug](https://github.com/elixir-plug/plug) and/or
[WebSock](https://github.com/phoenixframework/websock) APIs. As such there really isn't a whole lot of
user-visible surface area to Bandit, and as a consequence the API documentation presented here
is somewhat sparse. This is by design! Bandit is intended to 'just work' in almost all cases;
the only thought users typically have to put into Bandit comes in the choice of which options (if
any) they would like to change when starting a Bandit server. The sparseness of the Bandit API
should not be taken as an indicator of the comprehensiveness or robustness of the project.
#{@external_resource |> File.read!() |> String.split("<!-- MDOC -->") |> Enum.fetch!(1)}
"""
@typedoc """
Possible top-level options to configure a Bandit server
* `plug`: The Plug to use to handle connections. Can be specified as `MyPlug` or `{MyPlug, plug_opts}`
* `scheme`: One of `:http` or `:https`. If `:https` is specified, you will also need to specify
valid `certfile` and `keyfile` values (or an equivalent value within
`thousand_island_options.transport_options`). Defaults to `:http`
* `port`: The TCP port to listen on. This option is offered as a convenience and actually sets
the option of the same name within `thousand_island_options`. If a string value is passed, it
will be parsed as an integer. Defaults to 4000 if `scheme` is `:http`, and 4040 if `scheme` is
`:https`
* `ip`: The interface(s) to listen on. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Can be specified as:
* `{1, 2, 3, 4}` for IPv4 addresses
* `{1, 2, 3, 4, 5, 6, 7, 8}` for IPv6 addresses
* `:loopback` for local loopback (ie: `127.0.0.1`)
* `:any` for all interfaces (ie: `0.0.0.0`)
* `{:local, "/path/to/socket"}` for a Unix domain socket. If this option is used, the `port`
option *must* be set to `0`
* `inet`: Only bind to IPv4 interfaces. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Must be specified
as a bare atom `:inet`
* `inet6`: Only bind to IPv6 interfaces. This option is offered as a convenience and actually sets the
option of the same name within `thousand_island_options.transport_options`. Must be specified
as a bare atom `:inet6`
* `keyfile`: The path to a file containing the SSL key to use for this server. This option is
offered as a convenience and actually sets the option of the same name within
`thousand_island_options.transport_options`. If a relative path is used here, you will also
need to set the `otp_app` parameter and ensure that the named file is part of your application
build
* `certfile`: The path to a file containing the SSL certificate to use for this server. This option is
offered as a convenience and actually sets the option of the same name within
`thousand_island_options.transport_options`. If a relative path is used here, you will also
need to set the `otp_app` parameter and ensure that the named file is part of your application
build
* `otp_app`: Provided as a convenience when using relative paths for `keyfile` and `certfile`
* `cipher_suite`: Used to define a pre-selected set of ciphers, as described by
`Plug.SSL.configure/1`. Optional, can be either `:strong` or `:compatible`
* `display_plug`: The plug to use when describing the connection in logs. Useful for situations
such as Phoenix code reloading where you have a 'wrapper' plug but wish to refer to the
connection by the endpoint name
* `startup_log`: The log level at which Bandit should log startup info.
Defaults to `:info` log level, can be set to false to disable it
* `thousand_island_options`: A list of options to pass to Thousand Island. Bandit sets some
default values in this list based on your top-level configuration; these values will be
overridden by values appearing here. A complete list can be found at
`t:ThousandIsland.options/0`
* `http_options`: A list of options to configure the shared aspects of Bandit's HTTP stack. A
complete list can be found at `t:http_options/0`
* `http_1_options`: A list of options to configure Bandit's HTTP/1 stack. A complete list can
be found at `t:http_1_options/0`
* `http_2_options`: A list of options to configure Bandit's HTTP/2 stack. A complete list can
be found at `t:http_2_options/0`
* `websocket_options`: A list of options to configure Bandit's WebSocket stack. A complete list can
be found at `t:websocket_options/0`
"""
@type options :: [
{:plug, module() | {module(), Plug.opts()}}
| {:scheme, :http | :https}
| {:port, :inet.port_number()}
| {:ip, :inet.socket_address()}
| :inet
| :inet6
| {:keyfile, binary()}
| {:certfile, binary()}
| {:otp_app, Application.app()}
| {:cipher_suite, :strong | :compatible}
| {:display_plug, module()}
| {:startup_log, Logger.level() | false}
| {:thousand_island_options, ThousandIsland.options()}
| {:http_options, http_options()}
| {:http_1_options, http_1_options()}
| {:http_2_options, http_2_options()}
| {:websocket_options, websocket_options()}
]
@typedoc """
Options to configure shared aspects of the HTTP stack in Bandit
* `compress`: Whether or not to attempt compression of responses via content-encoding
negotiation as described in
[RFC9110§8.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4). Defaults to true
* `response_encodings`: A list of compression encodings, expressed in order of preference.
Defaults to `~w(zstd gzip x-gzip deflate)`, with `zstd` only being present on platforms which
have the zstd library compiled in
* `deflate_options`: A keyword list of options to set on the deflate library. A complete list can
be found at `t:deflate_options/0`. Note that these options only affect the behaviour of the
'deflate' content encoding; 'gzip' does not have any configurable options (this is a
limitation of the underlying `:zlib` library)
* `zstd_options`: A map of options passed verbatim to :zstd, review the options [here](https://www.erlang.org/doc/apps/stdlib/zstd.html#t:compress_parameters/0)
* `log_exceptions_with_status_codes`: Which exceptions to log. Bandit will log only those
exceptions whose status codes (as determined by `Plug.Exception.status/1`) match the specified
list or range. Defaults to `500..599`
* `log_protocol_errors`: How to log protocol errors such as malformed requests. `:short` will
log a single-line summary, while `:verbose` will log full stack traces. The value of `false`
will disable protocol error logging entirely. Defaults to `:short`
* `log_client_closures`: How to log cases where the client closes the connection. These happen
routinely in the real world and so the handling of them is configured separately since they
can be quite noisy. Takes the same options as `log_protocol_errors`, but defaults to `false`
"""
@type http_options :: [
{:compress, boolean()}
| {:response_encodings, list()}
| {:deflate_options, deflate_options()}
| {:zstd_options, zstd_options()}
| {:log_exceptions_with_status_codes, list() | Range.t()}
| {:log_protocol_errors, :short | :verbose | false}
| {:log_client_closures, :short | :verbose | false}
]
@typedoc """
Options to configure the HTTP/1 stack in Bandit
* `enabled`: Whether or not to serve HTTP/1 requests. Defaults to true
* `max_request_line_length`: The maximum permitted length of the request line
(expressed as the number of bytes on the wire) in an HTTP/1.1 request. Defaults to 10_000 bytes
* `max_header_length`: The maximum permitted length of any single header (combined
key & value, expressed as the number of bytes on the wire) in an HTTP/1.1 request. Defaults to 10_000 bytes
* `max_header_count`: The maximum permitted number of headers in an HTTP/1.1 request.
Defaults to 50 headers
* `max_requests`: The maximum number of requests to serve in a single
HTTP/1.1 connection before closing the connection. Defaults to 0 (no limit)
* `clear_process_dict`: Whether to clear the process dictionary of all non-internal entries
between subsequent keepalive requests. If set, all keys not starting with `$` are removed from
the process dictionary between requests. Defaults to `true`
* `gc_every_n_keepalive_requests`: How often to run a full garbage collection pass between subsequent
keepalive requests on the same HTTP/1.1 connection. Defaults to 5 (garbage collect between
every 5 requests). This option is currently experimental, and may change at any time
* `log_unknown_messages`: Whether or not to log unknown messages sent to the handler process.
Defaults to `false`
"""
@type http_1_options :: [
{:enabled, boolean()}
| {:max_request_line_length, pos_integer()}
| {:max_header_length, pos_integer()}
| {:max_header_count, pos_integer()}
| {:max_requests, pos_integer()}
| {:clear_process_dict, boolean()}
| {:gc_every_n_keepalive_requests, pos_integer()}
| {:log_unknown_messages, boolean()}
]
@typedoc """
Options to configure the HTTP/2 stack in Bandit
* `enabled`: Whether or not to serve HTTP/2 requests. Defaults to true
* `max_header_block_size`: The maximum permitted length of a field block of an HTTP/2 request
(expressed as the number of compressed bytes). Includes any concatenated block fragments from
continuation frames. Defaults to 50_000 bytes
* `max_requests`: The maximum number of requests to serve in a single
HTTP/2 connection before closing the connection. Defaults to 0 (no limit)
* `max_reset_stream_rate`: The maximum rate of stream resets (RST_STREAM frames) allowed.
Specified as a tuple of `{count, milliseconds}` where `count` is the maximum number of
RST_STREAM frames allowed within the time window of `milliseconds`. Defaults to `{500, 10_000}`
(500 resets per 10 seconds). Setting this to `nil` disables rate limiting
* `sendfile_chunk_size`: The maximum number of bytes read per sendfile chunk when streaming
HTTP/2 responses. Defaults to 1_048_576 (1 MiB)
* `default_local_settings`: Options to override the default values for local HTTP/2
settings. Values provided here will override the defaults specified in RFC9113§6.5.2
"""
@type http_2_options :: [
{:enabled, boolean()}
| {:max_header_block_size, pos_integer()}
| {:max_requests, pos_integer()}
| {:max_reset_stream_rate, {pos_integer(), pos_integer()} | nil}
| {:sendfile_chunk_size, pos_integer()}
| {:default_local_settings, keyword()}
]
@typedoc """
Options to configure the WebSocket stack in Bandit
* `enabled`: Whether or not to serve WebSocket upgrade requests. Defaults to true
* `max_frame_size`: The maximum size of a single WebSocket frame (expressed as
a number of bytes on the wire). Defaults to 0 (no limit)
* `validate_text_frames`: Whether or not to validate text frames as being UTF-8. Strictly
speaking this is required per RFC6455§5.6, however it can be an expensive operation and one
that may be safely skipped in some situations. Defaults to true
* `compress`: Whether or not to allow per-message deflate compression globally. Note that
upgrade requests still need to set the `compress: true` option in `connection_opts` on
a per-upgrade basis for compression to be negotiated (see 'WebSocket Support' section below
for details). Defaults to `true`
* `deflate_options`: A keyword list of options to set on the deflate library when using the
per-message deflate extension. A complete list can be found at `t:deflate_options/0`.
`window_bits` is currently ignored and left to negotiation.
"""
@type websocket_options :: [
{:enabled, boolean()}
| {:max_frame_size, pos_integer()}
| {:validate_text_frames, boolean()}
| {:compress, boolean()}
| {:deflate_options, deflate_options()}
]
@typedoc """
Options to configure the deflate library used for HTTP and WebSocket compression
"""
@type deflate_options :: [
{:level, :zlib.zlevel()}
| {:window_bits, :zlib.zwindowbits()}
| {:memory_level, :zlib.zmemlevel()}
| {:strategy, :zlib.zstrategy()}
]
@typedoc """
Options to configure the zstd library used for HTTP compression
"""
@type zstd_options :: map
@typep scheme :: :http | :https
require Logger
@doc false
@spec child_spec(options()) :: Supervisor.child_spec()
def child_spec(arg) do
%{
id: {__MODULE__, make_ref()},
start: {__MODULE__, :start_link, [arg]},
type: :supervisor,
restart: :permanent
}
end
@top_level_keys ~w(plug scheme port ip keyfile certfile otp_app cipher_suite display_plug startup_log thousand_island_options http_options http_1_options http_2_options websocket_options)a
@http_keys ~w(compress response_encodings deflate_options zstd_options log_exceptions_with_status_codes log_protocol_errors log_client_closures)a
@http_1_keys ~w(enabled max_request_line_length max_header_length max_header_count max_requests clear_process_dict gc_every_n_keepalive_requests log_unknown_messages)a
@http_2_keys ~w(enabled max_header_block_size max_requests max_reset_stream_rate sendfile_chunk_size default_local_settings)a
@websocket_keys ~w(enabled max_frame_size validate_text_frames compress deflate_options primitive_ops_module)a
@thousand_island_keys ThousandIsland.ServerConfig.__struct__()
|> Map.from_struct()
|> Map.keys()
@doc """
Starts a Bandit server using the provided arguments. See `t:options/0` for specific options to
pass to this function.
"""
@spec start_link(options()) :: Supervisor.on_start()
def start_link(arg) do
# Special case top-level `:inet` and `:inet6` options so we can use keyword logic everywhere else
arg = arg |> special_case_inet_options() |> validate_options(@top_level_keys, "top level")
thousand_island_options =
Keyword.get(arg, :thousand_island_options, [])
|> validate_options(@thousand_island_keys, :thousand_island_options)
http_options =
Keyword.get(arg, :http_options, [])
|> validate_options(@http_keys, :http_options)
http_1_options =
Keyword.get(arg, :http_1_options, [])
|> validate_options(@http_1_keys, :http_1_options)
http_2_options =
Keyword.get(arg, :http_2_options, [])
|> validate_options(@http_2_keys, :http_2_options)
websocket_options =
Keyword.get(arg, :websocket_options, [])
|> validate_options(@websocket_keys, :websocket_options)
{plug_mod, _} = plug = plug(arg)
display_plug = Keyword.get(arg, :display_plug, plug_mod)
startup_log = Keyword.get(arg, :startup_log, :info)
{http_1_enabled, http_1_options} = Keyword.pop(http_1_options, :enabled, true)
{http_2_enabled, http_2_options} = Keyword.pop(http_2_options, :enabled, true)
handler_options = %{
plug: plug,
handler_module: Bandit.InitialHandler,
opts: %{
http: http_options,
http_1: http_1_options,
http_2: http_2_options,
websocket: websocket_options
},
http_1_enabled: http_1_enabled,
http_2_enabled: http_2_enabled
}
scheme = Keyword.get(arg, :scheme, :http)
{transport_module, transport_options, default_port} =
case scheme do
:http ->
transport_options =
Keyword.take(arg, [:ip])
|> then(&(Keyword.get(thousand_island_options, :transport_options, []) ++ &1))
{ThousandIsland.Transports.TCP, transport_options, 4000}
:https ->
supported_protocols =
if(http_2_enabled, do: ["h2"], else: []) ++
if http_1_enabled, do: ["http/1.1"], else: []
transport_options =
Keyword.take(arg, [:ip, :keyfile, :certfile, :otp_app, :cipher_suite])
|> Keyword.merge(alpn_preferred_protocols: supported_protocols)
|> then(&(Keyword.get(thousand_island_options, :transport_options, []) ++ &1))
|> Plug.SSL.configure()
|> case do
{:ok, options} -> options
{:error, message} -> raise "Plug.SSL.configure/1 encountered error: #{message}"
end
|> Enum.reject(&(is_tuple(&1) and elem(&1, 0) == :otp_app))
{ThousandIsland.Transports.SSL, transport_options, 4040}
end
port = Keyword.get(arg, :port, default_port) |> parse_as_number()
thousand_island_options
|> Keyword.put_new(:port, port)
|> Keyword.put_new(:transport_module, transport_module)
|> Keyword.put(:transport_options, transport_options)
|> Keyword.put_new(:handler_module, Bandit.DelegatingHandler)
|> Keyword.put_new(:handler_options, handler_options)
|> ThousandIsland.start_link()
|> case do
{:ok, pid} ->
startup_log &&
Logger.log(startup_log, info(scheme, display_plug, pid), domain: [:bandit], plug: plug)
{:ok, pid}
{:error, {:shutdown, {:failed_to_start_child, :listener, :eaddrinuse}}} = error ->
Logger.error([info(scheme, display_plug, nil), " failed, port #{port} already in use"],
domain: [:bandit],
plug: plug
)
error
{:error, _} = error ->
error
end
end
@spec special_case_inet_options(options()) :: options()
defp special_case_inet_options(opts) do
{inet_opts, opts} = Enum.split_with(opts, &(&1 in [:inet, :inet6]))
if inet_opts == [] do
opts
else
Keyword.update(
opts,
:thousand_island_options,
[transport_options: inet_opts],
fn thousand_island_opts ->
Keyword.update(thousand_island_opts, :transport_options, inet_opts, &(&1 ++ inet_opts))
end
)
end
end
@spec validate_options(Keyword.t(), [atom(), ...], String.t() | atom()) ::
Keyword.t() | no_return()
defp validate_options(options, valid_values, name) do
case Keyword.split(options, valid_values) do
{options, []} ->
options
{_, illegal_options} ->
raise "Unsupported key(s) in #{name} config: #{inspect(Keyword.keys(illegal_options))}"
end
end
@spec plug(options()) :: {module(), Plug.opts()}
defp plug(arg) do
arg
|> Keyword.get(:plug)
|> case do
nil -> raise "A value is required for :plug"
{plug_fn, plug_options} when is_function(plug_fn, 2) -> {plug_fn, plug_options}
plug_fn when is_function(plug_fn) -> {plug_fn, []}
{plug, plug_options} when is_atom(plug) -> validate_plug(plug, plug_options)
plug when is_atom(plug) -> validate_plug(plug, [])
other -> raise "Invalid value for plug: #{inspect(other)}"
end
end
defp validate_plug(plug, plug_options) do
Code.ensure_loaded!(plug)
if !function_exported?(plug, :init, 1), do: raise("plug module does not define init/1")
if !function_exported?(plug, :call, 2), do: raise("plug module does not define call/2")
{plug, plug.init(plug_options)}
end
@spec parse_as_number(binary() | integer()) :: integer()
defp parse_as_number(value) when is_binary(value), do: String.to_integer(value)
defp parse_as_number(value) when is_integer(value), do: value
@spec info(scheme(), module(), nil | pid()) :: String.t()
defp info(scheme, plug, pid) do
server_vsn = Application.spec(:bandit)[:vsn]
"Running #{inspect(plug)} with Bandit #{server_vsn} at #{bound_address(scheme, pid)}"
end
@spec bound_address(scheme(), nil | pid()) :: String.t() | scheme()
defp bound_address(scheme, nil), do: scheme
defp bound_address(scheme, pid) do
{:ok, {address, port}} = ThousandIsland.listener_info(pid)
case address do
:local -> "#{_unix_path = port} (#{scheme}+unix)"
:undefined -> "#{inspect(port)} (#{scheme}+undefined)"
:unspec -> "unspec (#{scheme})"
address -> "#{:inet.ntoa(address)}:#{port} (#{scheme})"
end
end
end

View File

@@ -0,0 +1,294 @@
defmodule Bandit.Adapter do
@moduledoc false
# Implements the Plug-facing `Plug.Conn.Adapter` behaviour. These functions provide the primary
# mechanism for Plug applications to interact with a client, including functions to read the
# client body (if sent) and send response information back to the client. The concerns in this
# module are broadly about the semantics of HTTP in general, and less about transport-specific
# concerns, which are managed by the underlying `Bandit.HTTPTransport` implementation
@behaviour Plug.Conn.Adapter
@already_sent {:plug_conn, :sent}
defstruct transport: nil,
owner_pid: nil,
method: nil,
status: nil,
content_encoding: nil,
compression_context: nil,
upgrade: nil,
metrics: %{},
opts: []
@typedoc "A struct for backing a Plug.Conn.Adapter"
@type t :: %__MODULE__{
transport: Bandit.HTTPTransport.t(),
owner_pid: pid() | nil,
method: Plug.Conn.method() | nil,
status: Plug.Conn.status() | nil,
content_encoding: String.t(),
compression_context: Bandit.Compression.t() | nil,
upgrade: nil | {:websocket, opts :: keyword(), websocket_opts :: keyword()},
metrics: %{},
opts: %{
required(:http) => Bandit.http_options(),
required(:websocket) => Bandit.websocket_options()
}
}
def init(owner_pid, transport, method, headers, opts) do
content_encoding =
Bandit.Compression.negotiate_content_encoding(
Bandit.Headers.get_header(headers, "accept-encoding"),
opts.http
)
%__MODULE__{
transport: transport,
owner_pid: owner_pid,
method: method,
content_encoding: content_encoding,
metrics: %{req_header_end_time: Bandit.Telemetry.monotonic_time()},
opts: opts
}
end
@impl Plug.Conn.Adapter
def read_req_body(%__MODULE__{} = adapter, opts) do
validate_calling_process!(adapter)
metrics =
adapter.metrics
|> Map.put_new_lazy(:req_body_start_time, &Bandit.Telemetry.monotonic_time/0)
case Bandit.HTTPTransport.read_data(adapter.transport, opts) do
{:ok, body, transport} ->
body = IO.iodata_to_binary(body)
metrics =
metrics
|> Map.update(:req_body_bytes, byte_size(body), &(&1 + byte_size(body)))
|> Map.put(:req_body_end_time, Bandit.Telemetry.monotonic_time())
{:ok, body, %{adapter | transport: transport, metrics: metrics}}
{:more, body, transport} ->
body = IO.iodata_to_binary(body)
metrics =
metrics
|> Map.update(:req_body_bytes, byte_size(body), &(&1 + byte_size(body)))
{:more, body, %{adapter | transport: transport, metrics: metrics}}
end
end
##################
# Response Sending
##################
@impl Plug.Conn.Adapter
def send_resp(%__MODULE__{} = adapter, status, headers, body) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
# Save an extra iodata_length by checking common cases
empty_body? = Bandit.SocketHelpers.iodata_empty?(body)
{headers, compression_context} = Bandit.Compression.new(adapter, status, headers, empty_body?)
{compress_chunk, compression_context} =
Bandit.Compression.compress_chunk(body, compression_context)
{close_chunk, compression_metrics} = Bandit.Compression.close(compression_context)
encoded_body = [compress_chunk | close_chunk]
encoded_length = IO.iodata_length(encoded_body)
headers = Bandit.Headers.add_content_length(headers, encoded_length, status, adapter.method)
metrics =
adapter.metrics
|> Map.put(:resp_start_time, start_time)
|> Map.merge(compression_metrics)
adapter =
%{adapter | metrics: metrics}
|> send_headers(status, headers, :raw)
|> send_data(encoded_body, true)
send(adapter.owner_pid, @already_sent)
{:ok, nil, adapter}
end
@impl Plug.Conn.Adapter
def send_file(
%__MODULE__{} = adapter,
status,
headers,
path,
offset,
length
) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
{:ok, fileinfo} = :file.read_file_info(path, [:raw, time: :universal])
%File.Stat{type: :regular, size: size} = File.Stat.from_record(fileinfo)
length = if length == :all, do: size - offset, else: length
if offset + length <= size do
headers = Bandit.Headers.add_content_length(headers, length, status, adapter.method)
adapter = send_headers(adapter, status, headers, :raw)
{socket, bytes_actually_written} =
if send_resp_body?(adapter),
do: {Bandit.HTTPTransport.sendfile(adapter.transport, path, offset, length), length},
else: {adapter.transport, 0}
metrics =
adapter.metrics
|> Map.put(:resp_body_bytes, bytes_actually_written)
|> Map.put(:resp_start_time, start_time)
|> Map.put(:resp_end_time, Bandit.Telemetry.monotonic_time())
send(adapter.owner_pid, @already_sent)
{:ok, nil, %{adapter | transport: socket, metrics: metrics}}
else
raise "Cannot read #{length} bytes starting at #{offset} as #{path} is only #{size} octets in length"
end
end
@impl Plug.Conn.Adapter
def send_chunked(%__MODULE__{} = adapter, status, headers) do
validate_calling_process!(adapter)
start_time = Bandit.Telemetry.monotonic_time()
metrics = Map.put(adapter.metrics, :resp_start_time, start_time)
{headers, compression_context} = Bandit.Compression.new(adapter, status, headers, false, true)
adapter = %{adapter | metrics: metrics, compression_context: compression_context}
send(adapter.owner_pid, @already_sent)
{:ok, nil, send_headers(adapter, status, headers, :chunk_encoded)}
end
@impl Plug.Conn.Adapter
def chunk(%__MODULE__{} = adapter, chunk) do
# Sending an empty chunk implicitly ends the response. This is a bit of an undefined corner of
# the Plug.Conn.Adapter behaviour (see https://github.com/elixir-plug/plug/pull/535 for
# details) and ending the response here carves closest to the underlying HTTP/1.1 behaviour
# (RFC9112§7.1). Since there is no notion of chunked encoding is in HTTP/2 anyway (RFC9113§8.1)
# this entire section of the API is a bit slanty regardless.
validate_calling_process!(adapter)
# chunk/2 is unique among Plug.Conn.Adapter's sending callbacks in that it can return an error
# tuple instead of just raising or dying on error. Rescue here to implement this
try do
if Bandit.SocketHelpers.iodata_empty?(chunk) do
{encoded_chunk, compression_metrics} =
Bandit.Compression.close(adapter.compression_context)
adapter = %{adapter | metrics: Map.merge(adapter.metrics, compression_metrics)}
adapter =
if encoded_chunk != [] do
send_data(adapter, encoded_chunk, false)
else
adapter
end
{:ok, nil, send_data(adapter, "", true)}
else
{encoded_chunk, compression_context} =
Bandit.Compression.compress_chunk(chunk, adapter.compression_context)
adapter = %{adapter | compression_context: compression_context}
{:ok, nil, send_data(adapter, encoded_chunk, false)}
end
rescue
error in Bandit.TransportError -> {:error, error.error}
error -> {:error, Exception.message(error)}
end
end
@impl Plug.Conn.Adapter
def inform(%__MODULE__{} = adapter, status, headers) do
validate_calling_process!(adapter)
# It's a bit weird to be casing on the underlying version here, but whether or not to send
# an informational response is actually defined in RFC9110§15.2 so we consider it as an aspect
# of semantics that belongs here and not in the underlying transport
if get_http_protocol(adapter) == :"HTTP/1.0" do
{:error, :not_supported}
else
# inform/3 is unique in that headers comes in as a keyword list
headers = Enum.map(headers, fn {header, value} -> {to_string(header), value} end)
{:ok, send_headers(adapter, status, headers, :inform)}
end
end
defp send_headers(adapter, status, headers, body_disposition) do
headers =
if is_nil(Bandit.Headers.get_header(headers, "date")) do
[Bandit.Clock.date_header() | headers]
else
headers
end
adapter = %{adapter | status: status}
body_disposition = if send_resp_body?(adapter), do: body_disposition, else: :no_body
socket =
Bandit.HTTPTransport.send_headers(adapter.transport, status, headers, body_disposition)
%{adapter | transport: socket}
end
defp send_data(adapter, data, end_request) do
socket =
if send_resp_body?(adapter),
do: Bandit.HTTPTransport.send_data(adapter.transport, data, end_request),
else: adapter.transport
data_size = IO.iodata_length(data)
metrics = Map.update(adapter.metrics, :resp_body_bytes, data_size, &(&1 + data_size))
metrics =
if end_request,
do: Map.put(metrics, :resp_end_time, Bandit.Telemetry.monotonic_time()),
else: metrics
%{adapter | transport: socket, metrics: metrics}
end
defp send_resp_body?(%{method: "HEAD"}), do: false
defp send_resp_body?(%{status: 204}), do: false
defp send_resp_body?(%{status: 304}), do: false
defp send_resp_body?(_adapter), do: true
@impl Plug.Conn.Adapter
def upgrade(%__MODULE__{} = adapter, protocol, opts) do
if Keyword.get(adapter.opts.websocket, :enabled, true) &&
Bandit.HTTPTransport.supported_upgrade?(adapter.transport, protocol),
do: {:ok, %{adapter | upgrade: {protocol, opts, adapter.opts.websocket}}},
else: {:error, :not_supported}
end
@impl Plug.Conn.Adapter
def push(_adapter, _path, _headers), do: {:error, :not_supported}
@impl Plug.Conn.Adapter
def get_peer_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.peer_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_sock_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.sock_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_ssl_data(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.ssl_data(adapter.transport)
@impl Plug.Conn.Adapter
def get_http_protocol(%__MODULE__{} = adapter),
do: Bandit.HTTPTransport.version(adapter.transport)
defp validate_calling_process!(%{owner_pid: owner}) when owner == self(), do: :ok
defp validate_calling_process!(_), do: raise("Adapter functions must be called by stream owner")
end

View File

@@ -0,0 +1,14 @@
defmodule Bandit.Application do
@moduledoc false
use Application
@impl Application
@spec start(Application.start_type(), start_args :: term) ::
{:ok, pid}
| {:error, {:already_started, pid} | {:shutdown, term} | term}
def start(_type, _args) do
children = [Bandit.Clock]
Supervisor.start_link(children, strategy: :one_for_one)
end
end

View File

@@ -0,0 +1,56 @@
defmodule Bandit.Clock do
@moduledoc false
# Task which updates an ETS table with the current pre-formatted HTTP header
# timestamp once a second. This saves the individual request processes from
# having to construct this themselves, since it is a surprisingly expensive
# operation
use Task, restart: :permanent
require Logger
@doc """
Returns the current timestamp according to RFC9110§5.6.7.
If the timestamp doesn't exist in the ETS table or the table doesn't exist
the timestamp is newly created for every request
"""
@spec date_header() :: {header :: binary(), date :: binary()}
def date_header do
date =
try do
:ets.lookup_element(__MODULE__, :date_header, 2)
rescue
ArgumentError ->
Logger.warning("Header timestamp couldn't be fetched from ETS cache", domain: [:bandit])
get_date_header()
end
{"date", date}
end
@spec start_link(any()) :: {:ok, pid()}
def start_link(_opts) do
Task.start_link(__MODULE__, :init, [])
end
@spec init :: no_return()
def init do
__MODULE__ = :ets.new(__MODULE__, [:set, :protected, :named_table, {:read_concurrency, true}])
run()
end
@spec run() :: no_return()
defp run do
_ = update_header()
Process.sleep(1_000)
run()
end
@spec get_date_header() :: String.t()
defp get_date_header, do: Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %X GMT")
@spec update_header() :: true
defp update_header, do: :ets.insert(__MODULE__, {:date_header, get_date_header()})
end

View File

@@ -0,0 +1,168 @@
defmodule Bandit.Compression do
@moduledoc false
defstruct method: nil, bytes_in: 0, lib_context: nil
@typedoc "A struct containing the context for response compression"
@type t :: %__MODULE__{
method: :deflate | :gzip | :identity | :zstd,
bytes_in: non_neg_integer(),
lib_context: term()
}
@accepted_encodings ~w(gzip x-gzip deflate)
if Code.ensure_loaded?(:zstd) do
@accepted_encodings ~w(zstd) ++ @accepted_encodings
end
@spec negotiate_content_encoding(nil | binary(), keyword()) :: String.t() | nil
def negotiate_content_encoding(nil, _), do: nil
def negotiate_content_encoding(accept_encoding, http_opts) do
if Keyword.get(http_opts, :compress, true) do
client_accept_encoding = Plug.Conn.Utils.list(accept_encoding)
Keyword.get(http_opts, :response_encodings, @accepted_encodings)
|> Enum.find(&(&1 in client_accept_encoding))
else
nil
end
end
def new(adapter, status, headers, empty_body?, streamable \\ false) do
response_content_encoding_header = Bandit.Headers.get_header(headers, "content-encoding")
headers = maybe_add_vary_header(adapter, status, headers)
if status not in [204, 304] && not is_nil(adapter.content_encoding) &&
is_nil(response_content_encoding_header) &&
!response_has_strong_etag(headers) && !response_indicates_no_transform(headers) &&
!empty_body? do
case start_stream(adapter.content_encoding, adapter.opts.http, streamable) do
{:ok, context} -> {[{"content-encoding", adapter.content_encoding} | headers], context}
{:error, :unsupported_encoding} -> {headers, %__MODULE__{method: :identity}}
end
else
{headers, %__MODULE__{method: :identity}}
end
end
defp maybe_add_vary_header(adapter, status, headers) do
if status != 204 && Keyword.get(adapter.opts.http, :compress, true),
do: [{"vary", "accept-encoding"} | headers],
else: headers
end
defp response_has_strong_etag(headers) do
case Bandit.Headers.get_header(headers, "etag") do
nil -> false
"\W" <> _rest -> false
_strong_etag -> true
end
end
defp response_indicates_no_transform(headers) do
case Bandit.Headers.get_header(headers, "cache-control") do
nil -> false
header -> "no-transform" in Plug.Conn.Utils.list(header)
end
end
defp start_stream("deflate", http_opts, _streamable) do
opts = Keyword.get(http_opts, :deflate_options, [])
deflate_context = :zlib.open()
:zlib.deflateInit(
deflate_context,
Keyword.get(opts, :level, :default),
:deflated,
Keyword.get(opts, :window_bits, 15),
Keyword.get(opts, :mem_level, 8),
Keyword.get(opts, :strategy, :default)
)
{:ok, %__MODULE__{method: :deflate, lib_context: deflate_context}}
end
defp start_stream("x-gzip", _opts, false), do: {:ok, %__MODULE__{method: :gzip}}
defp start_stream("gzip", _opts, false), do: {:ok, %__MODULE__{method: :gzip}}
if Code.ensure_loaded?(:zstd) do
defp start_stream("zstd", http_opts, false) do
opts = Keyword.get(http_opts, :zstd_options, %{})
{:ok, zstd_context} = :zstd.context(:compress, opts)
{:ok, %__MODULE__{method: :zstd, lib_context: zstd_context}}
end
end
defp start_stream(_encoding, _opts, _streamable), do: {:error, :unsupported_encoding}
def compress_chunk(chunk, %__MODULE__{method: :deflate} = context) do
result = :zlib.deflate(context.lib_context, chunk, :sync)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
{result, context}
end
if Code.ensure_loaded?(:zstd) do
def compress_chunk(chunk, %__MODULE__{method: :zstd} = context) do
result = :zstd.compress(chunk, context.lib_context)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
{result, context}
end
end
def compress_chunk(chunk, %__MODULE__{method: :gzip, lib_context: nil} = context) do
result = :zlib.gzip(chunk)
context =
context
|> Map.update!(:bytes_in, &(&1 + IO.iodata_length(chunk)))
|> Map.put(:lib_context, :done)
{result, context}
end
def compress_chunk(chunk, %__MODULE__{method: :identity} = context) do
{chunk, context}
end
def close(%__MODULE__{} = context) do
chunk = close_context(context)
if context.method == :identity do
{chunk, %{}}
else
{chunk,
%{
resp_compression_method: to_string(context.method),
resp_uncompressed_body_bytes: context.bytes_in
}}
end
end
defp close_context(%__MODULE__{method: :deflate, lib_context: lib_context}) do
last = :zlib.deflate(lib_context, [], :finish)
:ok = :zlib.deflateEnd(lib_context)
:zlib.close(lib_context)
last
end
if Code.ensure_loaded?(:zstd) do
defp close_context(%__MODULE__{method: :zstd, lib_context: lib_context}) do
:zstd.close(lib_context)
[]
end
end
defp close_context(_context), do: []
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.DelegatingHandler do
@moduledoc false
# Delegates all implementation of the ThousandIsland.Handler behaviour
# to an implementation specified in state. Allows for clean separation
# between protocol implementations & friction free protocol selection &
# upgrades.
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, %{handler_module: handler_module} = state) do
handler_module.handle_connection(socket, state)
|> handle_bandit_continuation(socket)
end
@impl ThousandIsland.Handler
def handle_data(data, socket, %{handler_module: handler_module} = state) do
handler_module.handle_data(data, socket, state)
|> handle_bandit_continuation(socket)
end
@impl ThousandIsland.Handler
def handle_shutdown(socket, %{handler_module: handler_module} = state) do
handler_module.handle_shutdown(socket, state)
end
@impl ThousandIsland.Handler
def handle_close(socket, %{handler_module: handler_module} = state) do
handler_module.handle_close(socket, state)
end
@impl ThousandIsland.Handler
def handle_timeout(socket, %{handler_module: handler_module} = state) do
handler_module.handle_timeout(socket, state)
end
@impl ThousandIsland.Handler
def handle_error(error, socket, %{handler_module: handler_module} = state) do
handler_module.handle_error(error, socket, state)
end
@impl GenServer
def handle_call(msg, from, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_call(msg, from, state)
end
@impl GenServer
def handle_cast(msg, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_cast(msg, state)
end
@impl GenServer
def handle_info(msg, {_socket, %{handler_module: handler_module}} = state) do
handler_module.handle_info(msg, state)
end
defp handle_bandit_continuation(continuation, socket) do
case continuation do
{:switch, next_handler, state} ->
handle_connection(socket, %{state | handler_module: next_handler})
{:switch, next_handler, data, state} ->
case handle_connection(socket, %{state | handler_module: next_handler}) do
{:continue, state} ->
handle_data(data, socket, state)
{:continue, state, _timeout} ->
handle_data(data, socket, state)
other ->
other
end
other ->
other
end
end
end

View File

@@ -0,0 +1,113 @@
defmodule Bandit.Extractor do
@moduledoc false
# A state machine for efficiently extracting full frames from received packets
@type deserialize_result :: any()
@callback header_and_payload_length(binary(), max_frame_size :: integer()) ::
{:ok, {header_length :: integer(), payload_length :: integer()}}
| {:error, term()}
| :more
@callback deserialize(binary(), primitive_ops_module :: module()) :: deserialize_result()
@type t :: %__MODULE__{
header: binary(),
payload: iodata(),
payload_length: non_neg_integer(),
required_length: non_neg_integer(),
mode: :header_parsing | :payload_parsing,
max_frame_size: non_neg_integer(),
frame_parser: atom(),
primitive_ops_module: module()
}
defstruct header: <<>>,
payload: [],
payload_length: 0,
required_length: 0,
mode: :header_parsing,
max_frame_size: 0,
frame_parser: nil,
primitive_ops_module: nil
@spec new(module(), module(), Keyword.t()) :: t()
def new(frame_parser, primitive_ops_module, opts) do
max_frame_size = Keyword.get(opts, :max_frame_size, 0)
%__MODULE__{
max_frame_size: max_frame_size,
frame_parser: frame_parser,
primitive_ops_module: primitive_ops_module
}
end
@spec push_data(t(), binary()) :: t()
def push_data(%__MODULE__{} = state, data) do
case state do
%{mode: :header_parsing} ->
%{state | header: state.header <> data}
%{mode: :payload_parsing, payload: payload, payload_length: length} ->
%{state | payload: [payload, data], payload_length: length + byte_size(data)}
end
end
@spec pop_frame(t()) :: {t(), :more | deserialize_result()}
def pop_frame(state)
def pop_frame(%__MODULE__{mode: :header_parsing} = state) do
case state.frame_parser.header_and_payload_length(state.header, state.max_frame_size) do
{:ok, {header_length, required_length}} ->
state
|> transition_to_payload_parsing(header_length, required_length)
|> pop_frame()
{:error, message} ->
{state, {:error, message}}
:more ->
{state, :more}
end
end
def pop_frame(
%__MODULE__{
mode: :payload_parsing,
payload_length: payload_length,
required_length: required_length
} = state
) do
if payload_length >= required_length do
<<payload::binary-size(required_length), rest::binary>> =
IO.iodata_to_binary(state.payload)
frame = state.frame_parser.deserialize(state.header <> payload, state.primitive_ops_module)
state = transition_to_header_parsing(state, rest)
{state, frame}
else
{state, :more}
end
end
defp transition_to_payload_parsing(state, header_length, required_length) do
payload_length = byte_size(state.header) - header_length
state
|> Map.put(:header, binary_part(state.header, 0, header_length))
|> Map.put(:payload, binary_part(state.header, header_length, payload_length))
|> Map.put(:payload_length, payload_length)
|> Map.put(:required_length, required_length)
|> Map.put(:mode, :payload_parsing)
end
defp transition_to_header_parsing(state, rest) do
state
|> Map.put(:header, rest)
|> Map.put(:payload, [])
|> Map.put(:payload_length, 0)
|> Map.put(:required_length, 0)
|> Map.put(:mode, :header_parsing)
end
end

View File

@@ -0,0 +1,123 @@
defmodule Bandit.Headers do
@moduledoc false
# Conveniences for dealing with headers.
@spec is_port_number(integer()) :: Macro.t()
defguardp is_port_number(port) when Bitwise.band(port, 0xFFFF) === port
@spec get_header(Plug.Conn.headers(), header :: binary()) :: binary() | nil
def get_header(headers, header) do
case List.keyfind(headers, header, 0) do
{_, value} -> value
nil -> nil
end
end
# Covers IPv6 addresses, like `[::1]:4000` as defined in RFC3986.
@spec parse_hostlike_header!(host_header :: binary()) ::
{Plug.Conn.host(), nil | Plug.Conn.port_number()}
def parse_hostlike_header!("[" <> _ = host_header) do
host_header
|> :binary.split("]:")
|> case do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host <> "]", port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
[host] ->
{host, nil}
end
end
def parse_hostlike_header!(host_header) do
host_header
|> :binary.split(":")
|> case do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host, port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
[host] ->
{host, nil}
end
end
@spec get_content_length(Plug.Conn.headers()) ::
{:ok, nil | non_neg_integer()} | {:error, String.t()}
def get_content_length(headers) do
case get_header(headers, "content-length") do
nil -> {:ok, nil}
value -> parse_content_length(value)
end
end
@spec parse_content_length(binary()) :: {:ok, non_neg_integer()} | {:error, String.t()}
defp parse_content_length(value) do
case parse_integer(value) do
{length, ""} ->
{:ok, length}
{length, _rest} ->
if value |> Plug.Conn.Utils.list() |> Enum.all?(&(&1 == to_string(length))),
do: {:ok, length},
else: {:error, "invalid content-length header (RFC9112§6.3.5)"}
:error ->
{:error, "invalid content-length header (RFC9112§6.3.5)"}
end
end
# Parses non-negative integers from strings. Return the valid portion of an
# integer and the remaining string as a tuple like `{123, ""}` or `:error`.
@spec parse_integer(String.t()) :: {non_neg_integer(), rest :: String.t()} | :error
defp parse_integer(<<digit::8, rest::binary>>) when digit >= ?0 and digit <= ?9 do
parse_integer(rest, digit - ?0)
end
defp parse_integer(_), do: :error
@spec parse_integer(String.t(), non_neg_integer()) :: {non_neg_integer(), String.t()}
defp parse_integer(<<digit::8, rest::binary>>, total) when digit >= ?0 and digit <= ?9 do
parse_integer(rest, total * 10 + digit - ?0)
end
defp parse_integer(rest, total), do: {total, rest}
@spec add_content_length(
headers :: Plug.Conn.headers(),
length :: non_neg_integer(),
status :: Plug.Conn.int_status(),
method :: Plug.Conn.method()
) ::
Plug.Conn.headers()
# Per RFC9110§8.6, we use the following logic:
#
# * If the response is 1xx or 204, content-length is NEVER sent
# * If the response is 304 or the method is HEAD AND the body length is zero, respect any
# content-length header the plug may have set on the assumption that it knows what it would
# have sent
# * For all other responses, use the length of the provided response body as the content-length,
# overwriting any content-length the plug may have set
def add_content_length(headers, _length, status, _method)
when status in 100..199 or status == 204 do
drop_content_length(headers)
end
def add_content_length(headers, 0, status, method) when status == 304 or method == "HEAD" do
headers
end
def add_content_length(headers, length, _status, _method) do
[{"content-length", to_string(length)} | drop_content_length(headers)]
end
@spec drop_content_length(Plug.Conn.headers()) :: Plug.Conn.headers()
defp drop_content_length(headers) do
Enum.reject(headers, &(elem(&1, 0) == "content-length"))
end
end

View File

@@ -0,0 +1,30 @@
# HTTP/1 Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of HTTP/1.x as
defined in [RFC 9112](https://datatracker.ietf.org/doc/rfc9112).
## Process model
Within a Bandit server, an HTTP/1 connection is modeled as a single process.
This process is tied to the lifecycle of the underlying TCP connection; in the
case of an HTTP client which makes use of HTTP's keep-alive feature to make
multiple requests on the same connection, all of these requests will be serviced
by this same process.
The execution model to handle a given request is quite straightforward: the
underlying [Thousand Island](https://github.com/mtrudel/thousand_island) library
will call `Bandit.HTTP1.Handler.handle_data/3`, which will then construct a
`Bandit.HTTP1.Socket` struct that conforms to the `Bandit.HTTPTransport`
protocol. It will then call `Bandit.Pipeline.run/3`, which will go through the
process of reading the request (by calling functions on the
`Bandit.HTTPTransport` protocol), and constructing a `Plug.Conn` structure to
represent the request and subsequently pass it to the configured `Plug` module.
# Testing
All of this is exhaustively tested. Tests are located in `request_test.exs`, and
are broadly either concerned with testing network-facing aspects of the
implementation (ie: how well Bandit satisfies the relevant RFCs) or the Plug-facing
aspects of the implementation.
Unfortunately, there is no HTTP/1 equivalent to the external h2spec test suite.

View File

@@ -0,0 +1,96 @@
defmodule Bandit.HTTP1.Handler do
@moduledoc false
# An HTTP 1.0 & 1.1 Thousand Island Handler
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
transport = %Bandit.HTTP1.Socket{socket: socket, buffer: data, opts: state.opts}
connection_span = ThousandIsland.Socket.telemetry_span(socket)
conn_data = Bandit.SocketHelpers.conn_data(socket)
case Bandit.Pipeline.run(transport, state.plug, connection_span, conn_data, state.opts) do
{:ok, transport} -> maybe_keepalive(transport, state)
{:error, _reason} -> {:close, state}
{:upgrade, _transport, :websocket, opts} -> do_websocket_upgrade(opts, state)
end
end
defp maybe_keepalive(transport, state) do
requests_processed = Map.get(state, :requests_processed, 0) + 1
request_limit = Keyword.get(state.opts.http_1, :max_requests, 0)
under_limit = request_limit == 0 || requests_processed < request_limit
if under_limit && transport.keepalive do
if Keyword.get(state.opts.http_1, :clear_process_dict, true), do: clear_process_dict()
gc_every_n_requests = Keyword.get(state.opts.http_1, :gc_every_n_keepalive_requests, 5)
if rem(requests_processed, gc_every_n_requests) == 0, do: :erlang.garbage_collect()
state = Map.put(state, :requests_processed, requests_processed)
# We have bytes that we've read but haven't yet processed, tail call handle_data to start
# reading the next request
if Bandit.SocketHelpers.iodata_empty?(transport.buffer) do
{:continue, state}
else
handle_data(transport.buffer, transport.socket, state)
end
else
{:close, state}
end
end
defp clear_process_dict do
Process.get_keys()
|> Enum.each(
&if &1 not in ~w[$ancestors $initial_call $process_label]a, do: Process.delete(&1)
)
end
defp do_websocket_upgrade(upgrade_opts, state) do
:erlang.garbage_collect()
{:switch, Bandit.WebSocket.Handler, Map.put(state, :upgrade_opts, upgrade_opts)}
end
def handle_info({:plug_conn, :sent}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info({:EXIT, _pid, :normal}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info(msg, {socket, state}) do
if Keyword.get(state.opts.http_1, :log_unknown_messages, false), do: log_no_handle_info(msg)
{:noreply, {socket, state}, socket.read_timeout}
end
def handle_info(msg, state) do
log_no_handle_info(msg)
{:noreply, state}
end
defp log_no_handle_info(msg) do
# Copied verbatim from lib/elixir/lib/gen_server.ex
proc =
case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
:logger.error(
%{
label: {GenServer, :no_handle_info},
report: %{
module: __MODULE__,
message: msg,
name: proc
}
},
%{
domain: [:otp, :elixir],
error_logger: %{tag: :error_msg},
report_cb: &GenServer.format_report/1
}
)
end
end

View File

@@ -0,0 +1,503 @@
defmodule Bandit.HTTP1.Socket do
@moduledoc false
# This module implements the lower level parts of HTTP/1 (roughly, the aspects of the protocol
# described in RFC 9112 as opposed to RFC 9110). It is similar in spirit to
# `Bandit.HTTP2.Stream` for HTTP/2, and indeed both implement the `Bandit.HTTPTransport`
# behaviour. An instance of this struct is maintained as the state of a `Bandit.HTTP1.Handler`
# process, and it moves an HTTP/1 request through its lifecycle by calling functions defined on
# this module. This state is also tracked within the `Bandit.Adapter` instance that backs
# Bandit's Plug API.
defstruct socket: nil,
buffer: <<>>,
read_state: :unread,
write_state: :unsent,
unread_content_length: nil,
body_encoding: nil,
version: :"HTTP/1.0",
send_buffer: nil,
request_connection_header: nil,
keepalive: nil,
opts: %{}
@typedoc "An HTTP/1 read state"
@type read_state :: :unread | :headers_read | :read
@typedoc "An HTTP/1 write state"
@type write_state :: :unsent | :writing | :chunking | :chunk_streaming | :sent
@typedoc "The information necessary to communicate to/from a socket"
@type t :: %__MODULE__{
socket: ThousandIsland.Socket.t(),
buffer: iodata(),
read_state: read_state(),
write_state: write_state(),
unread_content_length: non_neg_integer() | :chunked | nil,
body_encoding: nil | binary(),
version: nil | :"HTTP/1.1" | :"HTTP/1.0",
send_buffer: iolist(),
request_connection_header: binary(),
keepalive: boolean(),
opts: %{
required(:http_1) => Bandit.http_1_options()
}
}
defimpl Bandit.HTTPTransport do
def peer_data(%@for{} = socket), do: Bandit.SocketHelpers.peer_data(socket.socket)
def sock_data(%@for{} = socket), do: Bandit.SocketHelpers.sock_data(socket.socket)
def ssl_data(%@for{} = socket), do: Bandit.SocketHelpers.ssl_data(socket.socket)
def version(%@for{} = socket), do: socket.version
def read_headers(%@for{read_state: :unread} = socket) do
{method, request_target, socket} = do_read_request_line!(socket)
{headers, socket} = do_read_headers!(socket)
content_length = get_content_length!(headers)
body_encoding = Bandit.Headers.get_header(headers, "transfer-encoding")
request_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
socket = %{socket | request_connection_header: request_connection_header}
case {content_length, body_encoding} do
{nil, nil} ->
# No body, so just go straight to 'read'
{:ok, method, request_target, headers, %{socket | read_state: :read}}
{content_length, nil} ->
socket = %{socket | read_state: :headers_read, unread_content_length: content_length}
{:ok, method, request_target, headers, socket}
{nil, body_encoding} ->
socket = %{socket | read_state: :headers_read, body_encoding: body_encoding}
{:ok, method, request_target, headers, socket}
{_content_length, _body_encoding} ->
request_error!(
"Request cannot contain both 'content-length' and 'transfer-encoding' (RFC9112§6.3.3)"
)
end
end
defp do_read_request_line!(socket, request_target \\ nil) do
packet_size = Keyword.get(socket.opts.http_1, :max_request_line_length, 10_000)
case :erlang.decode_packet(:http_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
do_read_request_line!(%{socket | buffer: socket.buffer <> chunk}, request_target)
{:ok, {:http_request, method, request_target, version}, rest} ->
version = get_version!(version)
# decode_packet is inconsistent about atom/string method returns
method = to_string(method)
request_target = resolve_request_target!(request_target, method)
socket = %{socket | buffer: rest, version: version}
{method, request_target, socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Request line HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Request URI is too long", :request_uri_too_long)
{:error, reason} ->
request_error!("Request line unknown error: #{inspect(reason)}")
end
end
defp get_version!({1, 1}), do: :"HTTP/1.1"
defp get_version!({1, 0}), do: :"HTTP/1.0"
defp get_version!(other), do: request_error!("Invalid HTTP version: #{inspect(other)}")
# Unwrap different request_targets returned by :erlang.decode_packet/3
defp resolve_request_target!({:abs_path, path}, _), do: {nil, nil, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, :undefined, path}, _),
do: {to_string(scheme), host, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, port, path}, _),
do: {to_string(scheme), host, port, path}
defp resolve_request_target!(:*, "OPTIONS"), do: {nil, nil, nil, :*}
defp resolve_request_target!({:scheme, scheme, port}, "CONNECT"),
do: {nil, scheme, port, nil}
defp resolve_request_target!(_request_target, _method),
do: request_error!("Unsupported request target (RFC9112§3.2)")
defp do_read_headers!(socket, headers \\ []) do
packet_size = Keyword.get(socket.opts.http_1, :max_header_length, 10_000)
case :erlang.decode_packet(:httph_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
socket = %{socket | buffer: socket.buffer <> chunk}
do_read_headers!(socket, headers)
{:ok, {:http_header, _, header, _, value}, rest} ->
socket = %{socket | buffer: rest}
headers = [{header |> to_string() |> String.downcase(:ascii), value} | headers]
if length(headers) <= Keyword.get(socket.opts.http_1, :max_header_count, 50) do
do_read_headers!(socket, headers)
else
request_error!("Too many headers", :request_header_fields_too_large)
end
{:ok, :http_eoh, rest} ->
socket = %{socket | read_state: :headers_read, buffer: rest}
{Enum.reverse(headers), socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Header read HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Header too long", :request_header_fields_too_large)
{:error, reason} ->
request_error!("Header read unknown error: #{inspect(reason)}")
end
end
defp get_content_length!(headers) do
case Bandit.Headers.get_content_length(headers) do
{:ok, content_length} -> content_length
{:error, reason} -> request_error!("Content length unknown error: #{inspect(reason)}")
end
end
def read_data(
%@for{read_state: :headers_read, unread_content_length: unread_content_length} = socket,
opts
)
when is_number(unread_content_length) do
{to_return, buffer, remaining_unread_content_length} =
do_read_content_length_data!(socket.socket, socket.buffer, unread_content_length, opts)
socket = %{socket | buffer: buffer, unread_content_length: remaining_unread_content_length}
if remaining_unread_content_length == 0 do
{:ok, to_return, %{socket | read_state: :read}}
else
{:more, to_return, socket}
end
end
def read_data(%@for{read_state: :headers_read, body_encoding: "chunked"} = socket, opts) do
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
{body, buffer} =
do_read_chunked_data!(socket.socket, socket.buffer, <<>>, read_size, read_timeout)
body = IO.iodata_to_binary(body)
{:ok, body, %{socket | read_state: :read, buffer: buffer}}
end
def read_data(%@for{read_state: :headers_read, body_encoding: body_encoding}, _opts)
when not is_nil(body_encoding) do
request_error!("Unsupported transfer-encoding")
end
def read_data(%@for{} = socket, _opts), do: {:ok, <<>>, socket}
@dialyzer {:no_improper_lists, do_read_content_length_data!: 4}
defp do_read_content_length_data!(socket, buffer, unread_content_length, opts) do
max_to_return = min(unread_content_length, Keyword.get(opts, :length, 8_000_000))
cond do
max_to_return == 0 ->
# We have already satisfied our content length
{<<>>, buffer, unread_content_length}
byte_size(buffer) >= max_to_return ->
# We can satisfy the read request entirely from our buffer
<<to_return::binary-size(max_to_return), rest::binary>> = buffer
{to_return, rest, unread_content_length - max_to_return}
byte_size(buffer) < max_to_return ->
# We need to read off the wire
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
to_return =
read!(socket, max_to_return - byte_size(buffer), [buffer], read_size, read_timeout)
|> IO.iodata_to_binary()
# We may have read more than we need to return
if byte_size(to_return) >= max_to_return do
<<to_return::binary-size(max_to_return), rest::binary>> = to_return
{to_return, rest, unread_content_length - max_to_return}
else
{to_return, <<>>, unread_content_length - byte_size(to_return)}
end
end
end
@dialyzer {:no_improper_lists, do_read_chunked_data!: 5}
defp do_read_chunked_data!(socket, buffer, body, read_size, read_timeout) do
case :binary.split(buffer, "\r\n") do
["0", "\r\n" <> rest] ->
# We should be reading (and ignoring) trailers here
{IO.iodata_to_binary(body), rest}
[chunk_size, rest] ->
chunk_size = String.to_integer(chunk_size, 16)
case rest do
<<next_chunk::binary-size(chunk_size), ?\r, ?\n, rest::binary>> ->
do_read_chunked_data!(socket, rest, [body, next_chunk], read_size, read_timeout)
_ ->
to_read = chunk_size - byte_size(rest)
if to_read > 0 do
iolist = read!(socket, to_read, [], read_size, read_timeout)
buffer = IO.iodata_to_binary([buffer | iolist])
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
else
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
_ ->
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
##################
# Internal Reading
##################
@compile {:inline, read_available_for_header!: 1}
@spec read_available_for_header!(ThousandIsland.Socket.t()) :: binary()
defp read_available_for_header!(socket) do
case ThousandIsland.Socket.recv(socket, 0) do
{:ok, chunk} -> chunk
{:error, reason} -> socket_error!(reason)
end
end
@compile {:inline, read_available!: 2}
@spec read_available!(ThousandIsland.Socket.t(), timeout()) :: binary()
defp read_available!(socket, read_timeout) do
case ThousandIsland.Socket.recv(socket, 0, read_timeout) do
{:ok, chunk} -> chunk
{:error, :timeout} -> <<>>
{:error, reason} -> socket_error!(reason)
end
end
@dialyzer {:no_improper_lists, read!: 5}
@spec read!(
ThousandIsland.Socket.t(),
non_neg_integer(),
iolist(),
non_neg_integer(),
timeout()
) ::
iolist()
defp read!(socket, to_read, already_read, read_size, read_timeout) do
case ThousandIsland.Socket.recv(socket, min(to_read, read_size), read_timeout) do
{:ok, chunk} ->
remaining_bytes = to_read - byte_size(chunk)
if remaining_bytes > 0 do
read!(socket, remaining_bytes, [already_read | chunk], read_size, read_timeout)
else
[already_read | chunk]
end
{:error, :timeout} ->
handle_timeout_with_disconnect_check!(socket)
{:error, reason} ->
socket_error!(reason)
end
end
# After a timeout, check if the peer is still connected. If not, this is
# likely a client disconnect that manifested as a timeout.
# We raise TransportError for disconnects and HTTPError for genuine timeouts.
# Use a non-blocking recv (timeout: 0) to detect closed connections.
@spec handle_timeout_with_disconnect_check!(ThousandIsland.Socket.t()) :: no_return()
defp handle_timeout_with_disconnect_check!(socket) do
case ThousandIsland.Socket.recv(socket, 0, 0) do
{:error, :timeout} ->
# Socket is still open but no data - genuine timeout
request_error!("Body read timeout", :request_timeout)
{:error, reason} ->
# Socket error (e.g., :closed) - client disconnected
socket_error!(reason)
{:ok, _data} ->
# Unexpected: data arrived just after timeout. Treat as timeout
# since we already committed to the timeout path.
request_error!("Body read timeout", :request_timeout)
end
end
def send_headers(%@for{write_state: :unsent} = socket, status, headers, body_disposition) do
resp_line = "#{socket.version} #{status} #{Plug.Conn.Status.reason_phrase(status)}\r\n"
{headers, socket} = handle_keepalive(status, headers, socket)
has_content_length = Bandit.Headers.get_header(headers, "content-length") != nil
case body_disposition do
:raw ->
# This is an optimization for the common case of sending a non-encoded body (or file),
# and coalesces the header and body send calls into a single ThousandIsland.Socket.send/2
# call. This makes a _substantial_ difference in practice
%{socket | write_state: :writing, send_buffer: [resp_line | encode_headers(headers)]}
:chunk_encoded when not has_content_length ->
headers = [{"transfer-encoding", "chunked"} | headers]
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunking}
:chunk_encoded when has_content_length ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunk_streaming}
:no_body ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :sent}
:inform ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :unsent}
end
end
defp handle_keepalive(status, headers, socket) do
response_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
# Per RFC9112§9.3
cond do
status in 100..199 ->
{headers, socket}
socket.request_connection_header == "close" || response_connection_header == "close" ->
{headers, %{socket | keepalive: false}}
socket.version == :"HTTP/1.1" ->
{headers, %{socket | keepalive: true}}
socket.version == :"HTTP/1.0" && socket.request_connection_header == "keep-alive" ->
{[{"connection", "keep-alive"} | headers], %{socket | keepalive: true}}
true ->
{[{"connection", "close"} | headers], %{socket | keepalive: false}}
end
end
defp safe_downcase(str) when is_binary(str), do: String.downcase(str, :ascii)
defp safe_downcase(str), do: str
defp encode_headers(headers) do
headers
|> Enum.map(fn {k, v} -> [k, ": ", v, "\r\n"] end)
|> then(&[&1 | ["\r\n"]])
end
def send_data(%@for{write_state: :writing} = socket, data, end_request) do
send!(socket.socket, [socket.send_buffer | data])
write_state = if end_request, do: :sent, else: :writing
%{socket | write_state: write_state, send_buffer: []}
end
def send_data(%@for{write_state: :chunking} = socket, data, end_request) do
byte_size = data |> IO.iodata_length()
send!(socket.socket, [Integer.to_string(byte_size, 16), "\r\n", data, "\r\n"])
write_state = if end_request, do: :sent, else: :chunking
%{socket | write_state: write_state}
end
def send_data(%@for{write_state: :chunk_streaming} = socket, data, end_request) do
send!(socket.socket, data)
write_state = if end_request, do: :sent, else: :chunk_streaming
%{socket | write_state: write_state}
end
def sendfile(%@for{write_state: :writing} = socket, path, offset, length) do
send!(socket.socket, socket.send_buffer)
case ThousandIsland.Socket.sendfile(socket.socket, path, offset, length) do
{:ok, _bytes_written} -> %{socket | write_state: :sent}
{:error, reason} -> socket_error!(reason)
end
end
@spec send!(ThousandIsland.Socket.t(), iolist()) :: :ok | no_return()
defp send!(socket, payload) do
case ThousandIsland.Socket.send(socket, payload) do
:ok ->
:ok
{:error, reason} ->
# Prevent error handlers from possibly trying to send again
send(self(), {:plug_conn, :sent})
socket_error!(reason)
end
end
def ensure_completed(%@for{read_state: :read} = socket), do: socket
def ensure_completed(%@for{keepalive: false} = socket), do: socket
def ensure_completed(%@for{} = socket) do
case read_data(socket, []) do
{:ok, _data, socket} -> socket
{:more, _data, _socket} -> request_error!("Unable to read remaining data in request body")
end
rescue
e in [Bandit.HTTPError] ->
# If we got a timeout during ensure_completed (draining the body),
# check if the client actually disconnected.
if e.plug_status == :request_timeout do
handle_timeout_with_disconnect_check!(socket.socket)
else
reraise e, __STACKTRACE__
end
end
def supported_upgrade?(%@for{} = _socket, protocol), do: protocol == :websocket
def send_on_error(%@for{}, %Bandit.TransportError{}), do: :ok
def send_on_error(%@for{} = socket, error) do
receive do
{:plug_conn, :sent} -> %{socket | write_state: :sent}
after
0 ->
status = error |> Plug.Exception.status() |> Plug.Conn.Status.code()
try do
send_headers(socket, status, [{"connection", "close"}], :no_body)
rescue
_e in [Bandit.TransportError, Bandit.HTTPError] -> :ok
end
end
end
@spec request_error!(term()) :: no_return()
@spec request_error!(term(), Plug.Conn.status()) :: no_return()
defp request_error!(reason, plug_status \\ :bad_request) do
raise Bandit.HTTPError, message: to_string(reason), plug_status: plug_status
end
@spec socket_error!(term()) :: no_return()
defp socket_error!(reason) do
raise Bandit.TransportError, message: "Unrecoverable error: #{reason}", error: reason
end
end
end

View File

@@ -0,0 +1,108 @@
# HTTP/2 Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of HTTP/2 as
defined in [RFC 9110](https://datatracker.ietf.org/doc/rfc9110) & [RFC
9113](https://datatracker.ietf.org/doc/rfc9113)
## Process model
Within a Bandit server, an HTTP/2 connection is modeled as a set of processes:
* 1 process per connection, a `Bandit.HTTP2.Handler` module implementing the
`ThousandIsland.Handler` behaviour, and;
* 1 process per stream (i.e.: per HTTP request) within the connection, implemented as
a `Bandit.HTTP2.StreamProcess` process
Each of these processes model the majority of their state via a
`Bandit.HTTP2.Connection` & `Bandit.HTTP2.Stream` struct, respectively.
The lifetimes of these processes correspond to their role; a connection process lives for as long
as a client is connected, and a stream process lives only as long as is required to process
a single stream request within a connection.
Connection processes are the 'root' of each connection's process group, and are supervised by
Thousand Island in the same manner that `ThousandIsland.Handler` processes are usually supervised
(see the [project README](https://github.com/mtrudel/thousand_island) for details).
Stream processes are not supervised by design. The connection process starts new
stream processes as required, via a standard `start_link`
call, and manages the termination of the resultant linked stream processes by
handling `{:EXIT,...}` messages as described in the Elixir documentation. Each
stream process stays alive long enough to fully model an HTTP/2 stream,
beginning its life in the `:init` state and ending it in the `:closed` state (or
else by a stream or connection error being raised). This approach is aligned
with the realities of the HTTP/2 model, insofar as if a connection process
terminates there is no reason to keep its constituent stream processes around,
and if a stream process dies the connection should be able to handle this
without itself terminating. It also means that our process model is very
lightweight - there is no extra supervision overhead present because no such
supervision is required for the system to function in the desired way.
## Reading client data
The overall structure of the implementation is managed by the `Bandit.HTTP2.Handler` module, and
looks like the following:
1. Bytes are asynchronously received from ThousandIsland via the
`Bandit.HTTP2.Handler.handle_data/3` function
2. Frames are parsed from these bytes by calling the `Bandit.HTTP2.Frame.deserialize/2`
function. If successful, the parsed frame(s) are returned. We retain any unparsed bytes in
a buffer in order to attempt parsing them upon receipt of subsequent data from the client
3. Parsed frames are passed into the `Bandit.HTTP2.Connection` module along with a struct of
same module. Frames are processed via the `Bandit.HTTP2.Connection.handle_frame/3` function.
Connection-level frames are handled within the `Bandit.HTTP2.Connection`
struct, and stream-level frames are passed along to the corresponding stream
process, which is wholly responsible for managing all aspects of a stream's
state (which is tracked via the `Bandit.HTTP2.Stream` struct). The one
exception to this is the handling of frames sent to streams which have
already been closed (and whose corresponding processes have thus terminated).
Any such frames are discarded without effect.
4. This process is repeated every time we receive data from the client until the
`Bandit.HTTP2.Connection` module indicates that the connection should be closed, either
normally or due to error. Note that frame deserialization may end up returning a connection
error if the parsed frames fail specific criteria (generally, the frame parsing modules are
responsible for identifying errors as described in [section
6](https://datatracker.ietf.org/doc/html/rfc9113#section-6) of RFC 9113). In these cases, the
failure is passed through to the connection module for processing in order to coordinate an
orderly shutdown or client notification as appropriate
## Processing requests
The state of a particular stream are contained within a `Bandit.HTTP2.Stream`
struct, maintained within a `Bandit.HTTP2.StreamProcess` process. As part of the
stream's lifecycle, the server's configured Plug is called, with an instance of
the `Bandit.Adapter` struct being used to interface with the Plug. There
is a separation of concerns between the aspect of HTTP semantics managed by
`Bandit.Adapter` (roughly, those concerns laid out in
[RFC9110](https://datatracker.ietf.org/doc/html/rfc9110)) and the more
transport-specific HTTP/2 concerns managed by `Bandit.HTTP2.Stream` (roughly the
concerns specified in [RFC9113](https://datatracker.ietf.org/doc/html/rfc9113)).
# Testing
All of this is exhaustively tested. Tests are broken up primarily into `protocol_test.exs`, which
is concerned with aspects of the implementation relating to protocol conformance and
client-facing concerns, while `plug_test.exs` is concerned with aspects of the implementation
having to do with the Plug API and application-facing concerns. There are also more
unit-style tests covering frame serialization and deserialization.
In addition, the `h2spec` conformance suite is run via a `System` wrapper & executes the entirety
of the suite (in strict mode) against a running Bandit server.
## Limitations and Assumptions
Some limitations and assumptions of this implementation:
* This handler assumes that the HTTP/2 connection preface has already been consumed from the
client. The `Bandit.InitialHandler` module uses this preface to discriminate between various
HTTP versions when determining which handler to use
* Priority frames are parsed and validated, but do not induce any action on the part of the
server. There is no priority assigned to respective streams in terms of processing; all streams
are run in parallel as soon as they arrive
* While flow control is completely implemented here, the specific values used for upload flow
control (that is, the end that we control) are fixed. Specifically, we attempt to maintain
fairly large windows in order to not restrict client uploads (we 'slow-start' window changes
upon receipt of first byte, mostly to retain parity between connection and stream window
management since connection windows cannot be changed via settings). The majority of flow
control logic has been encapsulated in the `Bandit.HTTP2.FlowControl` module should future
refinement be required

View File

@@ -0,0 +1,473 @@
defmodule Bandit.HTTP2.Connection do
@moduledoc false
# Represents the state of an HTTP/2 connection, in a process-free manner. An instance of this
# struct is maintained as the state of a `Bandit.HTTP2.Handler` process, and it moves an HTTP/2
# connection through its lifecycle by calling functions defined on this module
require Logger
defstruct local_settings: %Bandit.HTTP2.Settings{},
remote_settings: %Bandit.HTTP2.Settings{},
fragment_frame: nil,
send_hpack_state: HPAX.new(4096),
recv_hpack_state: HPAX.new(4096),
send_window_size: 65_535,
recv_window_size: 65_535,
streams: %Bandit.HTTP2.StreamCollection{},
pending_sends: [],
conn_data: nil,
telemetry_span: nil,
plug: nil,
opts: %{},
reset_stream_timestamps: []
@typedoc "Encapsulates the state of an HTTP/2 connection"
@type t :: %__MODULE__{
local_settings: Bandit.HTTP2.Settings.t(),
remote_settings: Bandit.HTTP2.Settings.t(),
fragment_frame: Bandit.HTTP2.Frame.Headers.t() | nil,
send_hpack_state: term(),
recv_hpack_state: term(),
send_window_size: non_neg_integer(),
recv_window_size: non_neg_integer(),
streams: Bandit.HTTP2.StreamCollection.t(),
pending_sends: [{Bandit.HTTP2.Stream.stream_id(), iodata(), boolean(), fun()}],
conn_data: Bandit.Pipeline.conn_data(),
telemetry_span: ThousandIsland.Telemetry.t(),
plug: Bandit.Pipeline.plug_def(),
opts: %{
required(:http) => Bandit.http_options(),
required(:http_2) => Bandit.http_2_options()
},
reset_stream_timestamps: [integer()]
}
@spec init(ThousandIsland.Socket.t(), Bandit.Pipeline.plug_def(), map()) :: t()
def init(socket, plug, opts) do
connection = %__MODULE__{
local_settings:
struct!(Bandit.HTTP2.Settings, Keyword.get(opts.http_2, :default_local_settings, [])),
conn_data: Bandit.SocketHelpers.conn_data(socket),
telemetry_span: ThousandIsland.Socket.telemetry_span(socket),
plug: plug,
opts: opts
}
# Send SETTINGS frame per RFC9113§3.4
%Bandit.HTTP2.Frame.Settings{ack: false, settings: Map.from_struct(connection.local_settings)}
|> send_frame(socket, connection)
connection
end
#
# Receiving while expecting CONTINUATION frames is a special case (RFC9113§6.10); handle it first
#
@spec handle_frame(Bandit.HTTP2.Frame.frame(), ThousandIsland.Socket.t(), t()) :: t()
def handle_frame(
%Bandit.HTTP2.Frame.Continuation{end_headers: true, stream_id: stream_id} = frame,
socket,
%__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{stream_id: stream_id}} =
connection
) do
header_block = connection.fragment_frame.fragment <> frame.fragment
header_frame = %{connection.fragment_frame | end_headers: true, fragment: header_block}
handle_frame(header_frame, socket, %{connection | fragment_frame: nil})
end
def handle_frame(
%Bandit.HTTP2.Frame.Continuation{end_headers: false, stream_id: stream_id} = frame,
_socket,
%__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{stream_id: stream_id}} =
connection
) do
fragment = connection.fragment_frame.fragment <> frame.fragment
check_oversize_fragment!(fragment, connection)
fragment_frame = %{connection.fragment_frame | fragment: fragment}
%{connection | fragment_frame: fragment_frame}
end
def handle_frame(_frame, _socket, %__MODULE__{fragment_frame: %Bandit.HTTP2.Frame.Headers{}}) do
connection_error!("Expected CONTINUATION frame (RFC9113§6.10)")
end
#
# Connection-level receiving
#
def handle_frame(%Bandit.HTTP2.Frame.Settings{ack: true}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.Settings{ack: false} = frame, socket, connection) do
%Bandit.HTTP2.Frame.Settings{ack: true} |> send_frame(socket, connection)
# Merge whatever new settings were sent with our existing remote settings
remote_settings = struct(connection.remote_settings, frame.settings)
send_hpack_state = HPAX.resize(connection.send_hpack_state, remote_settings.header_table_size)
delta = remote_settings.initial_window_size - connection.remote_settings.initial_window_size
Bandit.HTTP2.StreamCollection.get_pids(connection.streams)
|> Enum.each(&Bandit.HTTP2.Stream.deliver_send_window_update(&1, delta))
do_pending_sends(socket, %{
connection
| remote_settings: remote_settings,
send_hpack_state: send_hpack_state
})
end
def handle_frame(%Bandit.HTTP2.Frame.Ping{ack: true}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.Ping{ack: false} = frame, socket, connection) do
%Bandit.HTTP2.Frame.Ping{ack: true, payload: frame.payload} |> send_frame(socket, connection)
connection
end
def handle_frame(%Bandit.HTTP2.Frame.Goaway{}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.WindowUpdate{stream_id: 0} = frame, socket, connection) do
case Bandit.HTTP2.FlowControl.update_send_window(
connection.send_window_size,
frame.size_increment
) do
{:ok, new_window} -> do_pending_sends(socket, %{connection | send_window_size: new_window})
{:error, error} -> connection_error!(error, Bandit.HTTP2.Errors.flow_control_error())
end
end
#
# Stream-level receiving
#
def handle_frame(%Bandit.HTTP2.Frame.WindowUpdate{} = frame, _socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_send_window_update(stream, frame.size_increment)
end)
%{connection | streams: streams}
end
def handle_frame(%Bandit.HTTP2.Frame.Headers{end_headers: true} = frame, _socket, connection) do
check_oversize_fragment!(frame.fragment, connection)
case HPAX.decode(frame.fragment, connection.recv_hpack_state) do
{:ok, headers, recv_hpack_state} ->
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_headers(stream, headers, frame.end_stream)
end)
%{connection | recv_hpack_state: recv_hpack_state, streams: streams}
_ ->
connection_error!("Header decode error", Bandit.HTTP2.Errors.compression_error())
end
end
def handle_frame(%Bandit.HTTP2.Frame.Headers{end_headers: false} = frame, _socket, connection) do
check_oversize_fragment!(frame.fragment, connection)
%{connection | fragment_frame: frame}
end
def handle_frame(%Bandit.HTTP2.Frame.Continuation{}, _socket, _connection) do
connection_error!("Received unexpected CONTINUATION frame (RFC9113§6.10)")
end
def handle_frame(%Bandit.HTTP2.Frame.Data{} = frame, socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_data(stream, frame.data, frame.end_stream)
end)
{recv_window_size, window_increment} =
Bandit.HTTP2.FlowControl.compute_recv_window(
connection.recv_window_size,
byte_size(frame.data)
)
if window_increment > 0 do
%Bandit.HTTP2.Frame.WindowUpdate{stream_id: 0, size_increment: window_increment}
|> send_frame(socket, connection)
end
%{connection | recv_window_size: recv_window_size, streams: streams}
end
def handle_frame(%Bandit.HTTP2.Frame.Priority{}, _socket, connection), do: connection
def handle_frame(%Bandit.HTTP2.Frame.RstStream{} = frame, _socket, connection) do
streams =
with_stream(connection, frame.stream_id, fn stream ->
Bandit.HTTP2.Stream.deliver_rst_stream(stream, frame.error_code)
end)
%{connection | streams: streams}
|> check_reset_stream_rate_limit!()
end
# Catch-all handler for unknown frame types
def handle_frame(%Bandit.HTTP2.Frame.Unknown{} = frame, _socket, connection) do
Logger.warning("Unknown frame (#{inspect(Map.from_struct(frame))})",
domain: [:bandit],
plug: connection.plug
)
connection
end
defp with_stream(connection, stream_id, fun) do
case Bandit.HTTP2.StreamCollection.get_pid(connection.streams, stream_id) do
pid when is_pid(pid) or pid == :closed ->
fun.(pid)
connection.streams
:new ->
new_stream!(connection, stream_id)
sendfile_chunk_size =
Keyword.get(connection.opts.http_2, :sendfile_chunk_size, 1_048_576)
stream =
Bandit.HTTP2.Stream.init(
self(),
stream_id,
connection.remote_settings.initial_window_size,
sendfile_chunk_size
)
case Bandit.HTTP2.StreamProcess.start_link(
stream,
connection.plug,
connection.telemetry_span,
connection.conn_data,
connection.opts
) do
{:ok, pid} ->
streams = Bandit.HTTP2.StreamCollection.insert(connection.streams, stream_id, pid)
with_stream(%{connection | streams: streams}, stream_id, fun)
_ ->
raise "Unable to start stream process"
end
:invalid ->
connection_error!("Received invalid stream identifier")
end
end
defp new_stream!(connection, stream_id) do
max_requests = Keyword.get(connection.opts.http_2, :max_requests, 0)
if max_requests != 0 and
max_requests <= Bandit.HTTP2.StreamCollection.stream_count(connection.streams) do
connection_error!("Connection count exceeded", Bandit.HTTP2.Errors.refused_stream())
end
if connection.local_settings.max_concurrent_streams <=
Bandit.HTTP2.StreamCollection.open_stream_count(connection.streams) do
stream_error!(
"Concurrent stream count exceeded",
stream_id,
Bandit.HTTP2.Errors.refused_stream()
)
end
end
defp check_oversize_fragment!(fragment, connection) do
if byte_size(fragment) > Keyword.get(connection.opts.http_2, :max_header_block_size, 50_000),
do: connection_error!("Received overlong headers")
end
@spec check_reset_stream_rate_limit!(t()) :: t()
defp check_reset_stream_rate_limit!(connection) do
case Keyword.get(connection.opts.http_2, :max_reset_stream_rate, {500, 10_000}) do
nil ->
connection
{intensity, period} ->
now = :erlang.monotonic_time(:millisecond)
threshold = now - period
resets = connection.reset_stream_timestamps
recent_timestamps = can_reset(intensity - 1, threshold, resets, [], intensity, period)
%{connection | reset_stream_timestamps: [now | recent_timestamps]}
end
end
defp can_reset(_, _, [], acc, _, _),
do: :lists.reverse(acc)
defp can_reset(_, threshold, [restart | _], acc, _, _) when restart < threshold,
do: :lists.reverse(acc)
defp can_reset(0, _, [_ | _], _acc, intensity, period),
do:
connection_error!(
"Stream resets rate exceeded #{intensity} resets in #{period}ms",
Bandit.HTTP2.Errors.enhance_your_calm()
)
defp can_reset(n, threshold, [restart | restarts], acc, intensity, period),
do: can_reset(n - 1, threshold, restarts, [restart | acc], intensity, period)
# Shared logic to send any pending frames upon adjustment of our send window
defp do_pending_sends(socket, connection) do
connection.pending_sends
|> Enum.reverse()
|> Enum.reduce(connection, fn pending_send, connection ->
connection = connection |> Map.update!(:pending_sends, &List.delete(&1, pending_send))
{stream_id, rest, end_stream, on_unblock} = pending_send
send_data(stream_id, rest, end_stream, on_unblock, socket, connection)
end)
end
#
# Sending logic
#
# All callers of functions below will be from stream processes
#
#
# Stream-level sending
#
@spec send_headers(
Bandit.HTTP2.Stream.stream_id(),
Plug.Conn.headers(),
boolean(),
ThousandIsland.Socket.t(),
t()
) :: t()
def send_headers(stream_id, headers, end_stream, socket, connection) do
with enc_headers <- Enum.map(headers, fn {key, value} -> {:store, key, value} end),
{block, send_hpack_state} <- HPAX.encode(enc_headers, connection.send_hpack_state) do
%Bandit.HTTP2.Frame.Headers{
stream_id: stream_id,
end_stream: end_stream,
fragment: block
}
|> send_frame(socket, connection)
%{connection | send_hpack_state: send_hpack_state}
end
end
@spec send_data(
Bandit.HTTP2.Stream.stream_id(),
iodata(),
boolean(),
fun(),
ThousandIsland.Socket.t(),
t()
) :: t()
def send_data(stream_id, data, end_stream, on_unblock, socket, connection) do
with connection_window_size <- connection.send_window_size,
max_bytes_to_send <- max(connection_window_size, 0),
{data_to_send, bytes_to_send, rest} <- split_data(data, max_bytes_to_send),
connection <- %{connection | send_window_size: connection_window_size - bytes_to_send},
end_stream_to_send <- end_stream && byte_size(rest) == 0 do
if end_stream_to_send || bytes_to_send > 0 do
%Bandit.HTTP2.Frame.Data{
stream_id: stream_id,
end_stream: end_stream_to_send,
data: data_to_send
}
|> send_frame(socket, connection)
end
if byte_size(rest) == 0 do
on_unblock.()
connection
else
pending_sends = [{stream_id, rest, end_stream, on_unblock} | connection.pending_sends]
%{connection | pending_sends: pending_sends}
end
end
end
defp split_data(data, desired_length) do
data_length = IO.iodata_length(data)
if data_length <= desired_length do
{data, data_length, <<>>}
else
<<to_send::binary-size(desired_length), rest::binary>> = IO.iodata_to_binary(data)
{to_send, desired_length, rest}
end
end
@spec send_recv_window_update(
Bandit.HTTP2.Stream.stream_id(),
non_neg_integer(),
ThousandIsland.Socket.t(),
t()
) :: term()
def send_recv_window_update(stream_id, size_increment, socket, connection) do
%Bandit.HTTP2.Frame.WindowUpdate{stream_id: stream_id, size_increment: size_increment}
|> send_frame(socket, connection)
end
@spec send_rst_stream(
Bandit.HTTP2.Stream.stream_id(),
Bandit.HTTP2.Errors.error_code(),
ThousandIsland.Socket.t(),
t()
) :: term()
def send_rst_stream(stream_id, error_code, socket, connection) do
%Bandit.HTTP2.Frame.RstStream{stream_id: stream_id, error_code: error_code}
|> send_frame(socket, connection)
end
@spec stream_terminated(pid(), t()) :: t()
def stream_terminated(pid, connection) do
%{connection | streams: Bandit.HTTP2.StreamCollection.delete(connection.streams, pid)}
end
#
# Helper functions
#
@spec close_connection(Bandit.HTTP2.Errors.error_code(), term(), ThousandIsland.Socket.t(), t()) ::
{:close, t()} | {:error, term(), t()}
def close_connection(error_code, reason, socket, connection) do
last_stream_id = Bandit.HTTP2.StreamCollection.last_stream_id(connection.streams)
%Bandit.HTTP2.Frame.Goaway{last_stream_id: last_stream_id, error_code: error_code}
|> send_frame(socket, connection)
if error_code == Bandit.HTTP2.Errors.no_error(),
do: {:close, connection},
else: {:error, reason, connection}
end
@spec connection_error!(term()) :: no_return()
@spec connection_error!(term(), Bandit.HTTP2.Errors.error_code()) :: no_return()
defp connection_error!(message, error_code \\ Bandit.HTTP2.Errors.protocol_error()) do
raise Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code
end
@spec stream_error!(
String.t(),
Bandit.HTTP2.Stream.stream_id(),
Bandit.HTTP2.Errors.error_code()
) ::
no_return()
defp stream_error!(message, stream_id, error_code) do
raise Bandit.HTTP2.Errors.StreamError,
message: message,
error_code: error_code,
stream_id: stream_id
end
defp send_frame(frame, socket, connection) do
_ =
ThousandIsland.Socket.send(
socket,
Bandit.HTTP2.Frame.serialize(frame, connection.remote_settings.max_frame_size)
)
:ok
end
end

View File

@@ -0,0 +1,59 @@
defmodule Bandit.HTTP2.Errors do
@moduledoc false
# Errors as defined in RFC9113§7
@typedoc "An error code as defined for GOAWAY and RST_STREAM errors"
@type error_code() ::
(no_error :: 0x0)
| (protocol_error :: 0x1)
| (internal_error :: 0x2)
| (flow_control_error :: 0x3)
| (settings_timeout :: 0x4)
| (stream_closed :: 0x5)
| (frame_size_error :: 0x6)
| (refused_stream :: 0x7)
| (cancel :: 0x8)
| (compression_error :: 0x9)
| (connect_error :: 0xA)
| (enhance_your_calm :: 0xB)
| (inadequate_security :: 0xC)
| (http_1_1_requires :: 0xD)
error_codes = %{
no_error: 0x0,
protocol_error: 0x1,
internal_error: 0x2,
flow_control_error: 0x3,
settings_timeout: 0x4,
stream_closed: 0x5,
frame_size_error: 0x6,
refused_stream: 0x7,
cancel: 0x8,
compression_error: 0x9,
connect_error: 0xA,
enhance_your_calm: 0xB,
inadequate_security: 0xC,
http_1_1_requires: 0xD
}
@spec to_reason(integer()) :: atom()
for {name, value} <- error_codes do
@spec unquote(name)() :: unquote(Macro.var(name, Elixir)) :: unquote(value)
def unquote(name)(), do: unquote(value)
def to_reason(unquote(value)), do: unquote(name)
end
def to_reason(_), do: :unknown
# Represents a stream error as defined in RFC9113§5.4.2
defmodule StreamError do
defexception [:message, :error_code, :stream_id]
end
# Represents a stream error as defined in RFC9113§5.4.3
defmodule ConnectionError do
defexception [:message, :error_code]
end
end

View File

@@ -0,0 +1,43 @@
defmodule Bandit.HTTP2.FlowControl do
@moduledoc false
# Helpers for working with flow control window calculations
import Bitwise
@max_window_increment (1 <<< 31) - 1
@max_window_size (1 <<< 31) - 1
@min_window_size 1 <<< 30
@spec compute_recv_window(non_neg_integer(), non_neg_integer()) ::
{non_neg_integer(), non_neg_integer()}
def compute_recv_window(recv_window_size, data_size) do
# This is what our window size will be after receiving data_size bytes
recv_window_size = recv_window_size - data_size
if recv_window_size > @min_window_size do
# We have room to go before we need to update our window
{recv_window_size, 0}
else
# We want our new window to be as large as possible, but are limited by both the maximum size
# of the window (2^31-1) and the maximum size of the increment we can send to the client, both
# per RFC9113§6.9. Be careful about handling cases where we have a negative window due to
# misbehaving clients or network races
new_recv_window_size = min(recv_window_size + @max_window_increment, @max_window_size)
# Finally, determine what increment to send to the client
increment = new_recv_window_size - recv_window_size
{new_recv_window_size, increment}
end
end
@spec update_send_window(non_neg_integer(), non_neg_integer()) ::
{:ok, non_neg_integer()} | {:error, String.t()}
def update_send_window(current_send_window, increment) do
if current_send_window + increment > @max_window_size do
{:error, "Invalid WINDOW_UPDATE increment RFC9113§6.9.1"}
else
{:ok, current_send_window + increment}
end
end
end

View File

@@ -0,0 +1,106 @@
defmodule Bandit.HTTP2.Frame do
@moduledoc false
@typedoc "Indicates a frame type"
@type frame_type :: non_neg_integer()
@typedoc "The flags passed along with a frame"
@type flags :: byte()
@typedoc "A valid HTTP/2 frame"
@type frame ::
Bandit.HTTP2.Frame.Data.t()
| Bandit.HTTP2.Frame.Headers.t()
| Bandit.HTTP2.Frame.Priority.t()
| Bandit.HTTP2.Frame.RstStream.t()
| Bandit.HTTP2.Frame.Settings.t()
| Bandit.HTTP2.Frame.Ping.t()
| Bandit.HTTP2.Frame.Goaway.t()
| Bandit.HTTP2.Frame.WindowUpdate.t()
| Bandit.HTTP2.Frame.Continuation.t()
| Bandit.HTTP2.Frame.Unknown.t()
@spec deserialize(binary(), non_neg_integer()) ::
{{:ok, frame()}, iodata()}
| {{:more, iodata()}, <<>>}
| {{:error, Bandit.HTTP2.Errors.error_code(), binary()}, iodata()}
| nil
def deserialize(
<<length::24, type::8, flags::8, _reserved::1, stream_id::31,
payload::binary-size(length), rest::binary>>,
max_frame_size
)
when length <= max_frame_size do
type
|> case do
0x0 -> Bandit.HTTP2.Frame.Data.deserialize(flags, stream_id, payload)
0x1 -> Bandit.HTTP2.Frame.Headers.deserialize(flags, stream_id, payload)
0x2 -> Bandit.HTTP2.Frame.Priority.deserialize(flags, stream_id, payload)
0x3 -> Bandit.HTTP2.Frame.RstStream.deserialize(flags, stream_id, payload)
0x4 -> Bandit.HTTP2.Frame.Settings.deserialize(flags, stream_id, payload)
0x5 -> Bandit.HTTP2.Frame.PushPromise.deserialize(flags, stream_id, payload)
0x6 -> Bandit.HTTP2.Frame.Ping.deserialize(flags, stream_id, payload)
0x7 -> Bandit.HTTP2.Frame.Goaway.deserialize(flags, stream_id, payload)
0x8 -> Bandit.HTTP2.Frame.WindowUpdate.deserialize(flags, stream_id, payload)
0x9 -> Bandit.HTTP2.Frame.Continuation.deserialize(flags, stream_id, payload)
unknown -> Bandit.HTTP2.Frame.Unknown.deserialize(unknown, flags, stream_id, payload)
end
|> then(&{&1, rest})
end
# This is a little more aggressive than necessary. RFC9113§4.2 says we only need
# to treat frame size violations as connection level errors if the frame in
# question would affect the connection as a whole, so we could be more surgical
# here and send stream level errors in some cases. However, we are well within
# our rights to consider such errors as connection errors
def deserialize(
<<length::24, _type::8, _flags::8, _reserved::1, _stream_id::31,
_payload::binary-size(length), rest::binary>>,
max_frame_size
)
when length > max_frame_size do
{{:error, Bandit.HTTP2.Errors.frame_size_error(), "Payload size too large (RFC9113§4.2)"},
rest}
end
# nil is used to indicate for Stream.unfold/2 that the frame deserialization is finished
def deserialize(<<>>, _max_frame_size) do
nil
end
def deserialize(msg, _max_frame_size) do
{{:more, msg}, <<>>}
end
defmodule Flags do
@moduledoc false
import Bitwise
defguard set?(flags, bit) when band(flags, bsl(1, bit)) != 0
defguard clear?(flags, bit) when band(flags, bsl(1, bit)) == 0
@spec set([0..255]) :: 0..255
def set([]), do: 0x0
def set([bit | rest]), do: bor(bsl(1, bit), set(rest))
end
defprotocol Serializable do
@moduledoc false
@spec serialize(any(), non_neg_integer()) :: [
{Bandit.HTTP2.Frame.frame_type(), Bandit.HTTP2.Frame.flags(),
Bandit.HTTP2.Stream.stream_id(), iodata()}
]
def serialize(frame, max_frame_size)
end
@spec serialize(frame(), non_neg_integer()) :: iolist()
def serialize(frame, max_frame_size) do
frame
|> Serializable.serialize(max_frame_size)
|> Enum.map(fn {type, flags, stream_id, payload} ->
[<<IO.iodata_length(payload)::24, type::8, flags::8, 0::1, stream_id::31>>, payload]
end)
end
end

View File

@@ -0,0 +1,57 @@
defmodule Bandit.HTTP2.Frame.Continuation do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_headers: false,
fragment: nil
@typedoc "An HTTP/2 CONTINUATION frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_headers: boolean(),
fragment: iodata()
}
@end_headers_bit 2
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"CONTINUATION frame with zero stream_id (RFC9113§6.10)"}
end
def deserialize(flags, stream_id, <<fragment::binary>>) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_headers: set?(flags, @end_headers_bit),
fragment: fragment
}}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_headers_bit 2
def serialize(%Bandit.HTTP2.Frame.Continuation{} = frame, max_frame_size) do
fragment_length = IO.iodata_length(frame.fragment)
if fragment_length <= max_frame_size do
[{0x9, set([@end_headers_bit]), frame.stream_id, frame.fragment}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.fragment)
[
{0x9, 0x00, frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Continuation{stream_id: frame.stream_id, fragment: rest},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.HTTP2.Frame.Data do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_stream: false,
data: nil
@typedoc "An HTTP/2 DATA frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_stream: boolean(),
data: iodata()
}
@end_stream_bit 0
@padding_bit 3
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "DATA frame with zero stream_id (RFC9113§6.1)"}
end
def deserialize(flags, stream_id, <<padding_length::8, rest::binary>>)
when set?(flags, @padding_bit) and byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
data: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
def deserialize(flags, stream_id, <<data::binary>>) when clear?(flags, @padding_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
data: data
}}
end
def deserialize(flags, _stream_id, <<_padding_length::8, _rest::binary>>)
when set?(flags, @padding_bit) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"DATA frame with invalid padding length (RFC9113§6.1)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_stream_bit 0
def serialize(%Bandit.HTTP2.Frame.Data{} = frame, max_frame_size) do
data_length = IO.iodata_length(frame.data)
if data_length <= max_frame_size do
flags = if frame.end_stream, do: [@end_stream_bit], else: []
[{0x0, set(flags), frame.stream_id, frame.data}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.data)
[
{0x0, 0x00, frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Data{
stream_id: frame.stream_id,
end_stream: frame.end_stream,
data: rest
},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule Bandit.HTTP2.Frame.Goaway do
@moduledoc false
defstruct last_stream_id: 0, error_code: 0, debug_data: <<>>
@typedoc "An HTTP/2 GOAWAY frame"
@type t :: %__MODULE__{
last_stream_id: Bandit.HTTP2.Stream.stream_id(),
error_code: Bandit.HTTP2.Errors.error_code(),
debug_data: iodata()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(
_flags,
0,
<<_reserved::1, last_stream_id::31, error_code::32, debug_data::binary>>
) do
{:ok,
%__MODULE__{last_stream_id: last_stream_id, error_code: error_code, debug_data: debug_data}}
end
def deserialize(_flags, stream_id, _payload) when stream_id != 0 do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"Invalid stream ID in GOAWAY frame (RFC9113§6.8)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"GOAWAY frame with invalid payload size (RFC9113§6.8)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.Goaway{} = frame, _max_frame_size) do
[
{0x7, 0x0, 0,
<<0x0::1, frame.last_stream_id::31, frame.error_code::32, frame.debug_data::binary>>}
]
end
end
end

View File

@@ -0,0 +1,140 @@
defmodule Bandit.HTTP2.Frame.Headers do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct stream_id: nil,
end_stream: false,
end_headers: false,
exclusive_dependency: false,
stream_dependency: nil,
weight: nil,
fragment: nil
@typedoc "An HTTP/2 HEADERS frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
end_stream: boolean(),
end_headers: boolean(),
exclusive_dependency: boolean(),
stream_dependency: Bandit.HTTP2.Stream.stream_id() | nil,
weight: non_neg_integer() | nil,
fragment: iodata()
}
@end_stream_bit 0
@end_headers_bit 2
@padding_bit 3
@priority_bit 5
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"HEADERS frame with zero stream_id (RFC9113§6.2)"}
end
# Padding and priority
def deserialize(
flags,
stream_id,
<<padding_length::8, exclusive_dependency::1, stream_dependency::31, weight::8,
rest::binary>>
)
when set?(flags, @padding_bit) and set?(flags, @priority_bit) and
byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
exclusive_dependency: exclusive_dependency == 0x01,
stream_dependency: stream_dependency,
weight: weight,
fragment: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
# Padding but not priority
def deserialize(flags, stream_id, <<padding_length::8, rest::binary>>)
when set?(flags, @padding_bit) and clear?(flags, @priority_bit) and
byte_size(rest) >= padding_length do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
fragment: binary_part(rest, 0, byte_size(rest) - padding_length)
}}
end
# Any other case where padding is set
def deserialize(flags, _stream_id, <<_padding_length::8, _rest::binary>>)
when set?(flags, @padding_bit) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"HEADERS frame with invalid padding length (RFC9113§6.2)"}
end
def deserialize(
flags,
stream_id,
<<exclusive_dependency::1, stream_dependency::31, weight::8, fragment::binary>>
)
when set?(flags, @priority_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
exclusive_dependency: exclusive_dependency == 0x01,
stream_dependency: stream_dependency,
weight: weight,
fragment: fragment
}}
end
def deserialize(flags, stream_id, <<fragment::binary>>)
when clear?(flags, @priority_bit) and clear?(flags, @padding_bit) do
{:ok,
%__MODULE__{
stream_id: stream_id,
end_stream: set?(flags, @end_stream_bit),
end_headers: set?(flags, @end_headers_bit),
fragment: fragment
}}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@end_stream_bit 0
@end_headers_bit 2
def serialize(
%Bandit.HTTP2.Frame.Headers{
exclusive_dependency: false,
stream_dependency: nil,
weight: nil
} =
frame,
max_frame_size
) do
flags = if frame.end_stream, do: [@end_stream_bit], else: []
fragment_length = IO.iodata_length(frame.fragment)
if fragment_length <= max_frame_size do
[{0x1, set([@end_headers_bit | flags]), frame.stream_id, frame.fragment}]
else
<<this_frame::binary-size(max_frame_size), rest::binary>> =
IO.iodata_to_binary(frame.fragment)
[
{0x1, set(flags), frame.stream_id, this_frame}
| Bandit.HTTP2.Frame.Serializable.serialize(
%Bandit.HTTP2.Frame.Continuation{stream_id: frame.stream_id, fragment: rest},
max_frame_size
)
]
end
end
end
end

View File

@@ -0,0 +1,45 @@
defmodule Bandit.HTTP2.Frame.Ping do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
defstruct ack: false, payload: nil
@typedoc "An HTTP/2 PING frame"
@type t :: %__MODULE__{
ack: boolean(),
payload: iodata()
}
@ack_bit 0
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(flags, 0, <<payload::binary-size(8)>>) when set?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: true, payload: payload}}
end
def deserialize(flags, 0, <<payload::binary-size(8)>>) when clear?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: false, payload: payload}}
end
def deserialize(_flags, stream_id, _payload) when stream_id != 0 do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"Invalid stream ID in PING frame (RFC9113§6.7)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"PING frame with invalid payload size (RFC9113§6.7)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@ack_bit 0
def serialize(%Bandit.HTTP2.Frame.Ping{ack: true} = frame, _max_frame_size),
do: [{0x6, set([@ack_bit]), 0, frame.payload}]
def serialize(%Bandit.HTTP2.Frame.Ping{ack: false} = frame, _max_frame_size),
do: [{0x6, 0x0, 0, frame.payload}]
end
end

View File

@@ -0,0 +1,35 @@
defmodule Bandit.HTTP2.Frame.Priority do
@moduledoc false
defstruct stream_id: nil, dependent_stream_id: nil, weight: nil
@typedoc "An HTTP/2 PRIORITY frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
dependent_stream_id: Bandit.HTTP2.Stream.stream_id(),
weight: non_neg_integer()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"PRIORITY frame with zero stream_id (RFC9113§6.3)"}
end
def deserialize(_flags, stream_id, <<_reserved::1, dependent_stream_id::31, weight::8>>) do
{:ok,
%__MODULE__{stream_id: stream_id, dependent_stream_id: dependent_stream_id, weight: weight}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"Invalid payload size in PRIORITY frame (RFC9113§6.3)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.Priority{} = frame, _max_frame_size) do
[{0x2, 0x0, frame.stream_id, <<0::1, frame.dependent_stream_id::31, frame.weight::8>>}]
end
end
end

View File

@@ -0,0 +1,9 @@
defmodule Bandit.HTTP2.Frame.PushPromise do
@moduledoc false
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, _stream, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "PUSH_PROMISE frame received (RFC9113§8.4)"}
end
end

View File

@@ -0,0 +1,33 @@
defmodule Bandit.HTTP2.Frame.RstStream do
@moduledoc false
defstruct stream_id: nil, error_code: nil
@typedoc "An HTTP/2 RST_STREAM frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
error_code: Bandit.HTTP2.Errors.error_code()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, 0, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(),
"RST_STREAM frame with zero stream_id (RFC9113§6.4)"}
end
def deserialize(_flags, stream_id, <<error_code::32>>) do
{:ok, %__MODULE__{stream_id: stream_id, error_code: error_code}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"Invalid payload size in RST_STREAM frame (RFC9113§6.4)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.RstStream{} = frame, _max_frame_size) do
[{0x3, 0x0, frame.stream_id, <<frame.error_code::32>>}]
end
end
end

View File

@@ -0,0 +1,117 @@
defmodule Bandit.HTTP2.Frame.Settings do
@moduledoc false
import Bandit.HTTP2.Frame.Flags
import Bitwise
@max_window_size (1 <<< 31) - 1
@min_frame_size 1 <<< 14
@max_frame_size (1 <<< 24) - 1
defstruct ack: false, settings: nil
@typedoc "An HTTP/2 SETTINGS frame"
@type t :: %__MODULE__{ack: true, settings: nil} | %__MODULE__{ack: false, settings: map()}
@ack_bit 0
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(flags, 0, payload) when clear?(flags, @ack_bit) do
payload
|> Stream.unfold(fn
<<>> -> nil
<<setting::16, value::32, rest::binary>> -> {{:ok, {setting, value}}, rest}
<<rest::binary>> -> {{:error, rest}, <<>>}
end)
|> Enum.reduce_while({:ok, %{}}, fn
{:ok, {0x01, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :header_table_size, value)}}
{:ok, {0x02, val}}, {:ok, acc} when val in [0x00, 0x01] ->
{:cont, {:ok, acc}}
{:ok, {0x02, _value}}, {:ok, _acc} ->
{:halt,
{:error, Bandit.HTTP2.Errors.protocol_error(), "Invalid enable_push value (RFC9113§6.5)"}}
{:ok, {0x03, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_concurrent_streams, value)}}
{:ok, {0x04, value}}, {:ok, _acc} when value > @max_window_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.flow_control_error(), "Invalid window_size (RFC9113§6.5)"}}
{:ok, {0x04, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :initial_window_size, value)}}
{:ok, {0x05, value}}, {:ok, _acc} when value < @min_frame_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid max_frame_size (RFC9113§6.5)"}}
{:ok, {0x05, value}}, {:ok, _acc} when value > @max_frame_size ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid max_frame_size (RFC9113§6.5)"}}
{:ok, {0x05, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_frame_size, value)}}
{:ok, {0x06, value}}, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, :max_header_list_size, value)}}
{:ok, {_setting, _value}}, {:ok, acc} ->
{:cont, {:ok, acc}}
{:error, _rest}, _acc ->
{:halt,
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid SETTINGS size (RFC9113§6.5)"}}
end)
|> case do
{:ok, settings} -> {:ok, %__MODULE__{ack: false, settings: settings}}
{:error, error_code, reason} -> {:error, error_code, reason}
end
end
def deserialize(flags, 0, <<>>) when set?(flags, @ack_bit) do
{:ok, %__MODULE__{ack: true}}
end
def deserialize(flags, 0, _payload) when set?(flags, @ack_bit) do
{:error, Bandit.HTTP2.Errors.frame_size_error(),
"SETTINGS ack frame with non-empty payload (RFC9113§6.5)"}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.protocol_error(), "Invalid SETTINGS frame (RFC9113§6.5)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
@ack_bit 0
def serialize(%Bandit.HTTP2.Frame.Settings{ack: true}, _max_frame_size),
do: [{0x4, set([@ack_bit]), 0, <<>>}]
def serialize(%Bandit.HTTP2.Frame.Settings{ack: false} = frame, _max_frame_size) do
# Encode default settings values as empty binaries so that we do not send
# them. This means we can't restore settings back to default values if we
# change them, but since we don't ever change our settings this is fine
payload =
frame.settings
|> Enum.uniq_by(fn {setting, _} -> setting end)
|> Enum.map(fn
{:header_table_size, 4_096} -> <<>>
{:header_table_size, value} -> <<0x01::16, value::32>>
{:max_concurrent_streams, :infinity} -> <<>>
{:max_concurrent_streams, value} -> <<0x03::16, value::32>>
{:initial_window_size, 65_535} -> <<>>
{:initial_window_size, value} -> <<0x04::16, value::32>>
{:max_frame_size, 16_384} -> <<>>
{:max_frame_size, value} -> <<0x05::16, value::32>>
{:max_header_list_size, :infinity} -> <<>>
{:max_header_list_size, value} -> <<0x06::16, value::32>>
end)
[{0x4, 0x0, 0, payload}]
end
end
end

View File

@@ -0,0 +1,27 @@
defmodule Bandit.HTTP2.Frame.Unknown do
@moduledoc false
defstruct type: nil,
flags: nil,
stream_id: nil,
payload: nil
@typedoc "An HTTP/2 frame of unknown type"
@type t :: %__MODULE__{
type: Bandit.HTTP2.Frame.frame_type(),
flags: Bandit.HTTP2.Frame.flags(),
stream_id: Bandit.HTTP2.Stream.stream_id(),
payload: iodata()
}
# Note this is arity 4
@spec deserialize(
Bandit.HTTP2.Frame.frame_type(),
Bandit.HTTP2.Frame.flags(),
Bandit.HTTP2.Stream.stream_id(),
iodata()
) :: {:ok, t()}
def deserialize(type, flags, stream_id, payload) do
{:ok, %__MODULE__{type: type, flags: flags, stream_id: stream_id, payload: payload}}
end
end

View File

@@ -0,0 +1,33 @@
defmodule Bandit.HTTP2.Frame.WindowUpdate do
@moduledoc false
defstruct stream_id: nil,
size_increment: nil
@typedoc "An HTTP/2 WINDOW_UPDATE frame"
@type t :: %__MODULE__{
stream_id: Bandit.HTTP2.Stream.stream_id(),
size_increment: non_neg_integer()
}
@spec deserialize(Bandit.HTTP2.Frame.flags(), Bandit.HTTP2.Stream.stream_id(), iodata()) ::
{:ok, t()} | {:error, Bandit.HTTP2.Errors.error_code(), binary()}
def deserialize(_flags, _stream_id, <<_reserved::1, 0::31>>) do
{:error, Bandit.HTTP2.Errors.flow_control_error(),
"Invalid WINDOW_UPDATE size increment (RFC9113§6.9)"}
end
def deserialize(_flags, stream_id, <<_reserved::1, size_increment::31>>) do
{:ok, %__MODULE__{stream_id: stream_id, size_increment: size_increment}}
end
def deserialize(_flags, _stream_id, _payload) do
{:error, Bandit.HTTP2.Errors.frame_size_error(), "Invalid WINDOW_UPDATE frame (RFC9113§6.9)"}
end
defimpl Bandit.HTTP2.Frame.Serializable do
def serialize(%Bandit.HTTP2.Frame.WindowUpdate{} = frame, _max_frame_size) do
[{0x8, 0, frame.stream_id, <<0::1, frame.size_increment::31>>}]
end
end
end

View File

@@ -0,0 +1,186 @@
defmodule Bandit.HTTP2.Handler do
@moduledoc false
# An HTTP/2 handler, this module comprises the primary interface between Thousand Island and an
# HTTP connection. It is responsible for:
#
# * All socket-level sending and receiving from the client
# * Coordinating the parsing of frames & attendant error handling
# * Tracking connection state as represented by a `Bandit.HTTP2.Connection` struct
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
connection = Bandit.HTTP2.Connection.init(socket, state.plug, state.opts)
{:continue, Map.merge(state, %{buffer: <<>>, connection: connection})}
rescue
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
(state.buffer <> data)
|> Stream.unfold(
&Bandit.HTTP2.Frame.deserialize(&1, state.connection.local_settings.max_frame_size)
)
|> Enum.reduce_while(state, fn
{:ok, frame}, state ->
connection = Bandit.HTTP2.Connection.handle_frame(frame, socket, state.connection)
{:cont, %{state | connection: connection, buffer: <<>>}}
{:more, rest}, state ->
{:halt, %{state | buffer: rest}}
{:error, error_code, message}, _state ->
# We encountered an error while deserializing the frame. Let the connection figure out
# how to respond to it
raise Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code
end)
|> then(&{:continue, &1})
rescue
error in Bandit.HTTP2.Errors.StreamError -> rescue_stream_error(error, socket, state)
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
end
@impl ThousandIsland.Handler
def handle_shutdown(socket, state) do
Bandit.HTTP2.Connection.close_connection(
Bandit.HTTP2.Errors.no_error(),
"Server shutdown",
socket,
state.connection
)
end
@impl ThousandIsland.Handler
def handle_timeout(socket, state) do
Bandit.HTTP2.Connection.close_connection(
Bandit.HTTP2.Errors.no_error(),
"Client timeout",
socket,
state.connection
)
end
def handle_call({:peer_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.peer_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({:sock_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.sock_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({:ssl_data, _stream_id}, _from, {socket, state}) do
{:reply, Bandit.SocketHelpers.ssl_data(socket), {socket, state}, socket.read_timeout}
end
def handle_call({{:send_data, data, end_stream}, stream_id}, from, {socket, state}) do
# In 'normal' cases where there is sufficient space in the send windows for this message to be
# sent, Connection will call `unblock` synchronously in the `Connection.send_data` call below.
# In cases where there is not enough space in the connection window, Connection will call
# `unblock` at some point in the future once space opens up in the window. This
# keeps this code simple in that we can blindly send noreply here and let Connection handle
# the separate cases. This ensures that we have backpressure all the way back to the
# stream's handler process in the event of window overruns.
#
# Note that the above only applies to the connection-level send window; stream-level windows
# are managed internally by the stream and are not considered here at all. If the stream has
# managed to send this message, it is because there was enough room in the stream's send
# window to do so.
unblock = fn -> GenServer.reply(from, :ok) end
connection =
Bandit.HTTP2.Connection.send_data(
stream_id,
data,
end_stream,
unblock,
socket,
state.connection
)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_headers, headers, end_stream}, stream_id}, {socket, state}) do
connection =
Bandit.HTTP2.Connection.send_headers(
stream_id,
headers,
end_stream,
socket,
state.connection
)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_recv_window_update, size_increment}, stream_id}, {socket, state}) do
Bandit.HTTP2.Connection.send_recv_window_update(
stream_id,
size_increment,
socket,
state.connection
)
{:noreply, {socket, state}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:send_rst_stream, error_code}, stream_id}, {socket, state}) do
Bandit.HTTP2.Connection.send_rst_stream(stream_id, error_code, socket, state.connection)
{:noreply, {socket, state}, socket.read_timeout}
rescue
error -> rescue_error_handle_info(error, __STACKTRACE__, socket, state)
end
def handle_info({{:close_connection, error_code, msg}, _stream_id}, {socket, state}) do
_ = Bandit.HTTP2.Connection.close_connection(error_code, msg, socket, state.connection)
{:stop, :normal, {socket, state}}
end
def handle_info({:EXIT, pid, _reason}, {socket, state}) do
connection = Bandit.HTTP2.Connection.stream_terminated(pid, state.connection)
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
end
defp rescue_stream_error(error, socket, state) do
Bandit.HTTP2.Connection.send_rst_stream(
error.stream_id,
error.error_code,
socket,
state.connection
)
{:continue, state}
end
defp rescue_connection_error(error, stacktrace, socket, state) do
do_rescue_error(error, stacktrace, socket, state)
{:close, state}
end
defp rescue_error_handle_info(error, stacktrace, socket, state) do
do_rescue_error(error, stacktrace, socket, state)
{:stop, :normal}
end
defp do_rescue_error(error, stacktrace, socket, state) do
_ =
if state[:connection] do
Bandit.HTTP2.Connection.close_connection(
error.error_code,
error.message,
socket,
state[:connection]
)
end
Bandit.Logger.maybe_log_protocol_error(error, stacktrace, state.opts, plug: state.plug)
end
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.HTTP2.Settings do
@moduledoc """
Settings as defined in RFC9113§6.5.2
"""
defstruct header_table_size: 4_096,
max_concurrent_streams: :infinity,
initial_window_size: 65_535,
max_frame_size: 16_384,
max_header_list_size: :infinity
@typedoc "A collection of settings as defined in RFC9113§6.5"
@type t :: %__MODULE__{
header_table_size: non_neg_integer(),
max_concurrent_streams: non_neg_integer() | :infinity,
initial_window_size: non_neg_integer(),
max_frame_size: non_neg_integer(),
max_header_list_size: non_neg_integer() | :infinity
}
end

View File

@@ -0,0 +1,634 @@
defmodule Bandit.HTTP2.Stream do
@moduledoc false
# This module implements an HTTP/2 stream as described in RFC 9113, without concern for the higher-level
# HTTP semantics described in RFC 9110. It is similar in spirit to `Bandit.HTTP1.Socket` for
# HTTP/1, and indeed both implement the `Bandit.HTTPTransport` behaviour. An instance of this
# struct is maintained as the state of a `Bandit.HTTP2.StreamProcess` process, and it moves an
# HTTP/2 stream through its lifecycle by calling functions defined on this module. This state is
# also tracked within the `Bandit.Adapter` instance that backs Bandit's Plug API.
#
# A note about naming:
#
# This module has several intended callers, and due to its nature as a coordinator, needs to be
# careful about how it uses terms like 'read', 'send', 'receive', etc. To that end, there are
# some conventions in place:
#
# * Functions on this module which are intended to be called internally by the containing
# `Bandit.HTTP2.Connection` to pass information received from the client (such as headers or
# request data) to this stream. These functions are named `deliver_*`, and are intended to be
# called by the connection process. As such, they take a `stream_handle()` argument, which
# corresponds either to a pid (in the case of an active stream), or the value `:closed` (in the
# case of a stream which has already completed processing)
#
# * Functions on this module which are intended to be called by the higher-level implementation
# that is processing this stream are implemented via the `Bandit.HTTPTransport` protocol
#
# * In order for this stream to receive information from the containing connection process, we
# use carefully crafted `receive` calls (we do this in a manner that is safe to do within a
# GenServer). This work is handled internally by a number of functions named `do_recv_*`, which
# generally present a blocking interface in order to align with the expectations of the
# `Plug.Conn.Adapter` behaviour.
#
# This module also uses exceptions by convention rather than error tuples since many
# of these functions are called within `Plug.Conn.Adapter` calls, which makes it
# difficult to properly unwind many error conditions back to a place where we can properly shut
# down the stream by sending a RstStream frame to the client and terminating our process. The
# pattern here is to raise exceptions, and have the `Bandit.HTTP2.StreamProcess`'s `terminate/2`
# callback take care of calling back into us via the `reset_stream/2` and `close_connection/2`
# functions here, with the luxury of a nicely unwound stack and a process that is guaranteed to
# be terminated as soon as these functions are called
require Logger
defstruct connection_pid: nil,
stream_id: nil,
state: :idle,
recv_window_size: 65_535,
send_window_size: nil,
sendfile_chunk_size: nil,
bytes_remaining: nil,
read_timeout: 15_000
@typedoc "An HTTP/2 stream identifier"
@type stream_id :: non_neg_integer()
@typedoc "A handle to a stream, suitable for passing to the `deliver_*` functions on this module"
@type stream_handle :: pid() | :closed
@typedoc "An HTTP/2 stream state"
@type state :: :idle | :open | :local_closed | :remote_closed | :closed
@typedoc "The information necessary to communicate to/from a stream"
@type t :: %__MODULE__{
connection_pid: pid(),
stream_id: non_neg_integer(),
state: state(),
recv_window_size: non_neg_integer(),
send_window_size: non_neg_integer(),
sendfile_chunk_size: pos_integer(),
bytes_remaining: non_neg_integer() | nil,
read_timeout: timeout()
}
def init(connection_pid, stream_id, initial_send_window_size, sendfile_chunk_size) do
%__MODULE__{
connection_pid: connection_pid,
stream_id: stream_id,
send_window_size: initial_send_window_size,
sendfile_chunk_size: sendfile_chunk_size
}
end
# Collection API - Delivery
#
# These functions are intended to be called by the connection process which contains this
# stream. All of these start with `deliver_`
@spec deliver_headers(stream_handle(), Plug.Conn.headers(), boolean()) :: term()
def deliver_headers(:closed, _headers, _end_stream), do: :ok
def deliver_headers(pid, headers, end_stream),
do: send(pid, {:bandit, {:headers, headers, end_stream}})
@spec deliver_data(stream_handle(), iodata(), boolean()) :: term()
def deliver_data(:closed, _data, _end_stream), do: :ok
def deliver_data(pid, data, end_stream), do: send(pid, {:bandit, {:data, data, end_stream}})
@spec deliver_send_window_update(stream_handle(), non_neg_integer()) :: term()
def deliver_send_window_update(:closed, _delta), do: :ok
def deliver_send_window_update(pid, delta),
do: send(pid, {:bandit, {:send_window_update, delta}})
@spec deliver_rst_stream(stream_handle(), Bandit.HTTP2.Errors.error_code()) :: term()
def deliver_rst_stream(:closed, _error_code), do: :ok
def deliver_rst_stream(pid, error_code), do: send(pid, {:bandit, {:rst_stream, error_code}})
defimpl Bandit.HTTPTransport do
def peer_data(%@for{} = stream), do: call(stream, :peer_data, :infinity)
def sock_data(%@for{} = stream), do: call(stream, :sock_data, :infinity)
def ssl_data(%@for{} = stream), do: call(stream, :ssl_data, :infinity)
def version(%@for{}), do: :"HTTP/2"
def read_headers(%@for{state: :idle} = stream) do
case do_recv(stream, stream.read_timeout) do
{:headers, headers, stream} ->
method = Bandit.Headers.get_header(headers, ":method")
request_target = build_request_target!(headers, stream)
{pseudo_headers, headers} = split_headers!(headers, stream)
pseudo_headers_all_request!(pseudo_headers, stream)
exactly_one_instance_of!(pseudo_headers, ":scheme", stream)
exactly_one_instance_of!(pseudo_headers, ":method", stream)
exactly_one_instance_of!(pseudo_headers, ":path", stream)
headers_all_lowercase!(headers, stream)
no_connection_headers!(headers, stream)
valid_te_header!(headers, stream)
content_length = get_content_length!(headers, stream)
headers = combine_cookie_crumbs(headers)
stream = %{stream | bytes_remaining: content_length}
{:ok, method, request_target, headers, stream}
:timeout ->
stream_error!("Timed out waiting for HEADER", stream)
%@for{} = stream ->
read_headers(stream)
end
end
defp build_request_target!(headers, stream) do
scheme = Bandit.Headers.get_header(headers, ":scheme")
{host, port} = get_host_and_port!(headers)
path = get_path!(headers, stream)
{scheme, host, port, path}
end
defp get_host_and_port!(headers) do
case Bandit.Headers.get_header(headers, ":authority") do
authority when not is_nil(authority) -> Bandit.Headers.parse_hostlike_header!(authority)
nil -> {nil, nil}
end
end
# RFC9113§8.3.1 - path should be non-empty and absolute
defp get_path!(headers, stream) do
headers
|> Bandit.Headers.get_header(":path")
|> case do
nil -> stream_error!("Received empty :path", stream)
"*" -> :*
"/" <> _ = path -> split_path!(path, stream)
_ -> stream_error!("Path does not start with /", stream)
end
end
# RFC9113§8.3.1 - path should match the path-absolute production from RFC3986
defp split_path!(path, stream) do
if path |> String.split("/") |> Enum.all?(&(&1 not in [".", ".."])),
do: path,
else: stream_error!("Path contains dot segment", stream)
end
# RFC9113§8.3 - pseudo headers must appear first
defp split_headers!(headers, stream) do
{pseudo_headers, headers} =
Enum.split_while(headers, fn {key, _value} -> String.starts_with?(key, ":") end)
if Enum.any?(headers, fn {key, _value} -> String.starts_with?(key, ":") end),
do: stream_error!("Received pseudo headers after regular one", stream),
else: {pseudo_headers, headers}
end
# RFC9113§8.3.1 - only request pseudo headers may appear
defp pseudo_headers_all_request!(headers, stream) do
if Enum.any?(headers, fn {key, _value} ->
key not in ~w[:method :scheme :authority :path]
end),
do: stream_error!("Received invalid pseudo header", stream)
end
# RFC9113§8.3.1 - method, scheme, path pseudo headers must appear exactly once
defp exactly_one_instance_of!(headers, header, stream) do
if Enum.count(headers, fn {key, _value} -> key == header end) != 1,
do: stream_error!("Expected 1 #{header} headers", stream)
end
# RFC9113§8.2 - all headers name fields must be lowercsae
defp headers_all_lowercase!(headers, stream) do
if !Enum.all?(headers, fn {key, _value} -> lowercase?(key) end),
do: stream_error!("Received uppercase header", stream)
end
defp lowercase?(<<char, _rest::bits>>) when char >= ?A and char <= ?Z, do: false
defp lowercase?(<<_char, rest::bits>>), do: lowercase?(rest)
defp lowercase?(<<>>), do: true
# RFC9113§8.2.2 - no hop-by-hop headers
# Note that we do not filter out the TE header here, since it is allowed in
# specific cases by RFC9113§8.2.2. We check those cases in a separate filter
defp no_connection_headers!(headers, stream) do
connection_headers =
~w[connection keep-alive proxy-authenticate proxy-authorization trailers transfer-encoding upgrade]
if Enum.any?(headers, fn {key, _value} -> key in connection_headers end),
do: stream_error!("Received connection-specific header", stream)
end
# RFC9113§8.2.2 - TE header may be present if it contains exactly 'trailers'
defp valid_te_header!(headers, stream) do
if Bandit.Headers.get_header(headers, "te") not in [nil, "trailers"],
do: stream_error!("Received invalid TE header", stream)
end
defp get_content_length!(headers, stream) do
case Bandit.Headers.get_content_length(headers) do
{:ok, content_length} -> content_length
{:error, reason} -> stream_error!(reason, stream)
end
end
# RFC9113§8.2.3 - cookie headers may be split during transmission
defp combine_cookie_crumbs(headers) do
{crumbs, other_headers} =
headers |> Enum.split_with(fn {header, _} -> header == "cookie" end)
case Enum.map_join(crumbs, "; ", fn {"cookie", crumb} -> crumb end) do
"" -> other_headers
combined_cookie -> [{"cookie", combined_cookie} | other_headers]
end
end
def read_data(%@for{} = stream, opts) do
max_bytes = Keyword.get(opts, :length, 8_000_000)
timeout = Keyword.get(opts, :read_timeout, 15_000)
do_read_data(stream, max_bytes, timeout, [])
end
defp do_read_data(%@for{state: state} = stream, max_bytes, timeout, acc)
when state in [:open, :local_closed] do
case do_recv(stream, timeout) do
{:headers, trailers, stream} ->
no_pseudo_headers!(trailers, stream)
Logger.warning("Ignoring trailers #{inspect(trailers)}", domain: [:bandit])
do_read_data(stream, max_bytes, timeout, acc)
{:data, data, stream} ->
acc = [data | acc]
max_bytes = max_bytes - byte_size(data)
if max_bytes >= 0 do
do_read_data(stream, max_bytes, timeout, acc)
else
{:more, Enum.reverse(acc), stream}
end
:timeout ->
{:more, Enum.reverse(acc), stream}
%@for{} = stream ->
do_read_data(stream, max_bytes, timeout, acc)
end
end
defp do_read_data(%@for{state: :remote_closed} = stream, _max_bytes, _timeout, acc) do
{:ok, Enum.reverse(acc), stream}
end
defp no_pseudo_headers!(headers, stream) do
if Enum.any?(headers, fn {key, _value} -> String.starts_with?(key, ":") end),
do: stream_error!("Received trailers with pseudo headers", stream)
end
defp do_recv(%@for{state: :idle} = stream, timeout) do
receive do
{:bandit, {:headers, headers, end_stream}} ->
{:headers, headers, stream |> do_recv_headers() |> do_recv_end_stream(end_stream)}
{:bandit, {:data, _data, _end_stream}} ->
connection_error!("Received DATA in idle state")
{:bandit, {:send_window_update, _delta}} ->
connection_error!("Received WINDOW_UPDATE in idle state")
{:bandit, {:rst_stream, _error_code}} ->
connection_error!("Received RST_STREAM in idle state")
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: state} = stream, timeout)
when state in [:open, :local_closed] do
receive do
{:bandit, {:headers, headers, end_stream}} ->
{:headers, headers, stream |> do_recv_headers() |> do_recv_end_stream(end_stream)}
{:bandit, {:data, data, end_stream}} ->
{:data, data,
stream |> do_recv_data(data, end_stream) |> do_recv_end_stream(end_stream)}
{:bandit, {:send_window_update, delta}} ->
do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} ->
do_recv_rst_stream!(stream, error_code)
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: :remote_closed} = stream, timeout) do
receive do
{:bandit, {:headers, _headers, _end_stream}} ->
do_stream_closed_error!("Received HEADERS in remote_closed state", stream)
{:bandit, {:data, _data, _end_stream}} ->
do_stream_closed_error!("Received DATA in remote_closed state", stream)
{:bandit, {:send_window_update, delta}} ->
do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} ->
do_recv_rst_stream!(stream, error_code)
after
timeout -> :timeout
end
end
defp do_recv(%@for{state: :closed} = stream, timeout) do
receive do
{:bandit, {:headers, _headers, _end_stream}} -> stream
{:bandit, {:data, _data, _end_stream}} -> stream
{:bandit, {:send_window_update, _delta}} -> stream
{:bandit, {:rst_stream, _error_code}} -> stream
after
timeout -> :timeout
end
end
defp do_recv_headers(%@for{state: :idle} = stream), do: %{stream | state: :open}
defp do_recv_headers(stream), do: stream
defp do_recv_data(stream, data, end_stream) do
{new_window, increment} =
Bandit.HTTP2.FlowControl.compute_recv_window(stream.recv_window_size, byte_size(data))
if increment > 0 && !end_stream, do: do_send(stream, {:send_recv_window_update, increment})
bytes_remaining =
case stream.bytes_remaining do
nil -> nil
bytes_remaining -> bytes_remaining - byte_size(data)
end
%{stream | recv_window_size: new_window, bytes_remaining: bytes_remaining}
end
defp do_recv_end_stream(stream, false), do: stream
defp do_recv_end_stream(stream, true) do
next_state =
case stream.state do
:open -> :remote_closed
:local_closed -> :closed
end
if stream.bytes_remaining not in [nil, 0],
do: stream_error!("Received END_STREAM with byte still pending", stream)
%{stream | state: next_state}
end
defp do_recv_send_window_update(stream, delta) do
case Bandit.HTTP2.FlowControl.update_send_window(stream.send_window_size, delta) do
{:ok, new_window} ->
%{stream | send_window_size: new_window}
{:error, reason} ->
stream_error!(reason, stream, Bandit.HTTP2.Errors.flow_control_error())
end
end
@spec do_recv_rst_stream!(term(), term()) :: no_return()
defp do_recv_rst_stream!(_stream, error_code) do
case Bandit.HTTP2.Errors.to_reason(error_code) do
reason when reason in [:no_error, :cancel] ->
raise(Bandit.TransportError, message: "Client reset stream normally", error: :closed)
reason ->
raise(Bandit.TransportError,
message: "Received RST_STREAM from client: #{reason} (#{error_code})",
error: reason
)
end
end
@spec do_stream_closed_error!(String.t(), Bandit.HTTP2.Stream.t()) :: no_return()
defp do_stream_closed_error!(msg, stream),
do: stream_error!(msg, stream, Bandit.HTTP2.Errors.stream_closed())
# Stream API - Sending
def send_headers(%@for{state: state} = stream, status, headers, body_disposition)
when state in [:open, :remote_closed] do
# We need to map body_disposition into the state model of HTTP/2. This turns out to be really
# easy, since HTTP/2 only has one way to send data. The only bit we need from the disposition
# is whether there will be any data forthcoming (ie: whether or not to end the stream). That
# will possibly walk us to a different state per RFC9113§5.1, as determined by the tail call
# to set_state_on_send_end_stream/2
end_stream = body_disposition == :no_body
headers = [{":status", to_string(status)} | split_cookies(headers)]
do_send(stream, {:send_headers, headers, end_stream})
set_state_on_send_end_stream(stream, end_stream)
end
# RFC9113§8.2.3 - cookie headers may be split during transmission
defp split_cookies(headers) do
headers
|> Enum.flat_map(fn
{"cookie", cookie} ->
cookie |> String.split("; ") |> Enum.map(fn crumb -> {"cookie", crumb} end)
{header, value} ->
[{header, value}]
end)
end
def send_data(%@for{state: state} = stream, data, end_stream)
when state in [:open, :remote_closed] do
stream =
receive do
{:bandit, {:send_window_update, delta}} -> do_recv_send_window_update(stream, delta)
{:bandit, {:rst_stream, error_code}} -> do_recv_rst_stream!(stream, error_code)
after
0 -> stream
end
max_bytes_to_send = max(stream.send_window_size, 0)
{data_to_send, bytes_to_send, rest} = split_data(data, max_bytes_to_send)
stream =
if end_stream || bytes_to_send > 0 do
end_stream_to_send = end_stream && byte_size(rest) == 0
call(stream, {:send_data, data_to_send, end_stream_to_send}, :infinity)
%{stream | send_window_size: stream.send_window_size - bytes_to_send}
else
stream
end
if byte_size(rest) == 0 do
set_state_on_send_end_stream(stream, end_stream)
else
receive do
{:bandit, {:send_window_update, delta}} ->
stream
|> do_recv_send_window_update(delta)
|> send_data(rest, end_stream)
after
stream.read_timeout ->
stream_error!(
"Timeout waiting for space in the send_window",
stream,
Bandit.HTTP2.Errors.flow_control_error()
)
end
end
end
def sendfile(%@for{} = stream, path, offset, length) do
case :file.open(path, [:raw, :binary]) do
{:ok, fd} ->
try do
if length == 0 do
send_data(stream, "", true)
else
sendfile_loop(stream, fd, offset, length, 0)
end
after
:file.close(fd)
end
{:error, reason} ->
raise "Error opening file for sendfile: #{inspect(reason)}"
end
end
defp sendfile_loop(stream, _fd, _offset, length, sent) when sent >= length do
stream
end
defp sendfile_loop(stream, fd, offset, length, sent) do
read_size = min(length - sent, sendfile_chunk_size(stream))
case :file.pread(fd, offset + sent, read_size) do
{:ok, data} ->
now_sent = byte_size(data)
end_stream = sent + now_sent >= length
stream = send_data(stream, data, end_stream)
if end_stream do
stream
else
sendfile_loop(stream, fd, offset, length, sent + now_sent)
end
:eof ->
raise "Error reading file for sendfile: :eof"
{:error, reason} ->
raise "Error reading file for sendfile: #{inspect(reason)}"
end
end
defp sendfile_chunk_size(%@for{sendfile_chunk_size: sendfile_chunk_size}) do
max(sendfile_chunk_size, 1)
end
defp split_data(data, desired_length) do
data_length = IO.iodata_length(data)
if data_length <= desired_length do
{data, data_length, <<>>}
else
<<to_send::binary-size(desired_length), rest::binary>> = IO.iodata_to_binary(data)
{to_send, desired_length, rest}
end
end
defp set_state_on_send_end_stream(stream, false), do: stream
defp set_state_on_send_end_stream(%@for{state: :open} = stream, true),
do: %{stream | state: :local_closed}
defp set_state_on_send_end_stream(%@for{state: :remote_closed} = stream, true),
do: %{stream | state: :closed}
# Closing off the stream upon completion or error
def ensure_completed(%@for{state: :closed} = stream), do: stream
def ensure_completed(%@for{state: :local_closed} = stream) do
receive do
{:bandit, {:headers, _headers, true}} ->
do_recv_end_stream(stream, true)
{:bandit, {:data, data, true}} ->
do_recv_data(stream, data, true) |> do_recv_end_stream(true)
after
# RFC9113§8.1 - hint the client to stop sending data
0 -> do_send(stream, {:send_rst_stream, Bandit.HTTP2.Errors.no_error()})
end
end
def ensure_completed(%@for{state: state} = stream) do
stream_error!(
"Terminating stream in #{state} state",
stream,
Bandit.HTTP2.Errors.internal_error()
)
end
def supported_upgrade?(%@for{} = _stream, _protocol), do: false
def send_on_error(%@for{} = stream, %Bandit.HTTP2.Errors.StreamError{} = error) do
do_send(stream, {:send_rst_stream, error.error_code})
%{stream | state: :closed}
end
def send_on_error(%@for{} = stream, %Bandit.HTTP2.Errors.ConnectionError{} = error) do
do_send(stream, {:close_connection, error.error_code, error.message})
stream
end
def send_on_error(%@for{state: state} = stream, error) when state in [:idle, :open] do
stream = maybe_send_error(%{stream | state: :open}, error)
%{stream | state: :local_closed}
end
def send_on_error(%@for{state: :remote_closed} = stream, error) do
stream = maybe_send_error(%{stream | state: :open}, error)
%{stream | state: :closed}
end
def send_on_error(%@for{} = stream, _error), do: stream
defp maybe_send_error(stream, error) do
receive do
{:plug_conn, :sent} -> stream
after
0 ->
status = error |> Plug.Exception.status() |> Plug.Conn.Status.code()
send_headers(stream, status, [], :no_body)
end
end
# Helpers
defp do_send(stream, msg), do: send(stream.connection_pid, {msg, stream.stream_id})
defp call(stream, msg, timeout),
do: GenServer.call(stream.connection_pid, {msg, stream.stream_id}, timeout)
@spec stream_error!(String.t(), Bandit.HTTP2.Stream.t()) :: no_return()
@spec stream_error!(
String.t(),
Bandit.HTTP2.Stream.t(),
Bandit.HTTP2.Errors.error_code()
) :: no_return()
defp stream_error!(message, stream, error_code \\ Bandit.HTTP2.Errors.protocol_error()),
do:
raise(Bandit.HTTP2.Errors.StreamError,
message: message,
error_code: error_code,
stream_id: stream.stream_id
)
@spec connection_error!(term()) :: no_return()
@spec connection_error!(term(), Bandit.HTTP2.Errors.error_code()) :: no_return()
defp connection_error!(message, error_code \\ Bandit.HTTP2.Errors.protocol_error()),
do: raise(Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code)
end
end

View File

@@ -0,0 +1,78 @@
defmodule Bandit.HTTP2.StreamCollection do
@moduledoc false
# Represents a collection of stream IDs and what process IDs are running them. An instance of
# this struct is contained within each `Bandit.HTTP2.Connection` struct and is responsible for
# encapsulating the data about the streams which are currently active within the connection.
#
# This collection has a number of useful properties:
#
# * Process IDs are accessible by stream id
# * Process IDs are deletable by themselves (ie: deletion is via PID)
# * The collection is able to determine if a stream not currently contained in this collection
# represents a previously seen stream (in which case it is considered to be in a 'closed'
# state), or if it is a stream ID of a stream that has yet to be created
require Integer
defstruct last_stream_id: 0,
stream_count: 0,
id_to_pid: %{},
pid_to_id: %{}
@typedoc "A map from stream id to pid"
@type t :: %__MODULE__{
last_stream_id: Bandit.HTTP2.Stream.stream_id(),
stream_count: non_neg_integer(),
id_to_pid: %{Bandit.HTTP2.Stream.stream_id() => pid()},
pid_to_id: %{pid() => Bandit.HTTP2.Stream.stream_id()}
}
@spec get_pids(t()) :: [pid()]
def get_pids(collection), do: Map.values(collection.id_to_pid)
@spec get_pid(t(), Bandit.HTTP2.Stream.stream_id()) :: pid() | :new | :closed | :invalid
def get_pid(_collection, stream_id) when Integer.is_even(stream_id), do: :invalid
def get_pid(collection, stream_id) when stream_id > collection.last_stream_id, do: :new
def get_pid(collection, stream_id) do
case Map.get(collection.id_to_pid, stream_id) do
pid when is_pid(pid) -> pid
nil -> :closed
end
end
@spec insert(t(), Bandit.HTTP2.Stream.stream_id(), pid()) :: t()
def insert(collection, stream_id, pid) do
%__MODULE__{
last_stream_id: stream_id,
stream_count: collection.stream_count + 1,
id_to_pid: Map.put(collection.id_to_pid, stream_id, pid),
pid_to_id: Map.put(collection.pid_to_id, pid, stream_id)
}
end
# Dialyzer insists on the atom() here even though it doesn't make sense
@spec delete(t(), pid()) :: t() | atom()
def delete(collection, pid) do
case Map.pop(collection.pid_to_id, pid) do
{nil, _} ->
collection
{stream_id, new_pid_to_id} ->
%{
collection
| id_to_pid: Map.delete(collection.id_to_pid, stream_id),
pid_to_id: new_pid_to_id
}
end
end
@spec stream_count(t()) :: non_neg_integer()
def stream_count(collection), do: collection.stream_count
@spec open_stream_count(t()) :: non_neg_integer()
def open_stream_count(collection), do: collection.pid_to_id |> map_size()
@spec last_stream_id(t()) :: Bandit.HTTP2.Stream.stream_id()
def last_stream_id(collection), do: collection.last_stream_id
end

View File

@@ -0,0 +1,31 @@
defmodule Bandit.HTTP2.StreamProcess do
@moduledoc false
# This process runs the lifecycle of an HTTP/2 stream, which is modeled by a
# `Bandit.HTTP2.Stream` struct that this process maintains in its state
#
# As part of this lifecycle, the execution of a Plug to handle this stream's request
# takes place here; the entirety of the Plug lifecycle takes place in a single
# `c:handle_continue/2` call.
use GenServer, restart: :temporary
@spec start_link(
Bandit.HTTP2.Stream.t(),
Bandit.Pipeline.plug_def(),
Bandit.Telemetry.t(),
Bandit.Pipeline.conn_data(),
keyword()
) :: GenServer.on_start()
def start_link(stream, plug, connection_span, conn_data, opts) do
GenServer.start_link(__MODULE__, {stream, plug, connection_span, conn_data, opts})
end
@impl GenServer
def init(state), do: {:ok, state, {:continue, :start_stream}}
@impl GenServer
def handle_continue(:start_stream, {stream, plug, connection_span, conn_data, opts} = state) do
_ = Bandit.Pipeline.run(stream, plug, connection_span, conn_data, opts)
{:stop, :normal, state}
end
end

View File

@@ -0,0 +1,6 @@
defmodule Bandit.HTTPError do
# Represents an error suitable for return as an HTTP status. Note that these may be surfaced
# from anywhere that such a message is well defined, including within HTTP/1 transport concerns
# and also within shared HTTP semantics (ie: within Bandit.Adapter or Bandit.Pipeline)
defexception message: nil, plug_status: :bad_request
end

View File

@@ -0,0 +1,47 @@
defprotocol Bandit.HTTPTransport do
@moduledoc false
# A protocol implemented by the lower level transports (HTTP/1 and HTTP/2) to encapsulate the
# low-level mechanics needed to complete an HTTP request/response cycle. Implementations of this
# protocol should be broadly concerned with the protocol-specific aspects of a connection, and
# can rely on higher-level code taking care of shared HTTP semantics
@typedoc "How the response body is to be delivered"
@type body_disposition :: :raw | :chunk_encoded | :no_body | :inform
@spec peer_data(t()) :: Plug.Conn.Adapter.peer_data()
def peer_data(transport)
@spec sock_data(t()) :: Plug.Conn.Adapter.sock_data()
def sock_data(transport)
@spec ssl_data(t()) :: Plug.Conn.Adapter.ssl_data()
def ssl_data(transport)
@spec version(t()) :: Plug.Conn.Adapter.http_protocol()
def version(transport)
@spec read_headers(t()) ::
{:ok, Plug.Conn.method(), Bandit.Pipeline.request_target(), Plug.Conn.headers(), t()}
def read_headers(transport)
@spec read_data(t(), opts :: keyword()) :: {:ok, iodata(), t()} | {:more, iodata(), t()}
def read_data(transport, opts)
@spec send_headers(t(), Plug.Conn.status(), Plug.Conn.headers(), body_disposition()) :: t()
def send_headers(transport, status, heeaders, disposition)
@spec send_data(t(), data :: iodata(), end_request :: boolean()) :: t()
def send_data(transport, data, end_request)
@spec sendfile(t(), Path.t(), offset :: integer(), length :: integer() | :all) :: t()
def sendfile(transport, path, offset, length)
@spec ensure_completed(t()) :: t()
def ensure_completed(transport)
@spec supported_upgrade?(t(), atom()) :: boolean()
def supported_upgrade?(transport, protocol)
@spec send_on_error(t(), struct()) :: t()
def send_on_error(transport, error)
end

View File

@@ -0,0 +1,86 @@
defmodule Bandit.InitialHandler do
@moduledoc false
# The initial protocol implementation used for all connections. Switches to a
# specific protocol implementation based on configuration, ALPN negotiation, and
# line heuristics.
use ThousandIsland.Handler
require Logger
@type on_switch_handler ::
{:switch, bandit_http_handler(), data :: term(), state :: term()}
| {:switch, bandit_http_handler(), state :: term()}
@type bandit_http_handler :: Bandit.HTTP1.Handler | Bandit.HTTP2.Handler
# Attempts to guess the protocol in use, returning the applicable next handler and any
# data consumed in the course of guessing which must be processed by the actual protocol handler
@impl ThousandIsland.Handler
@spec handle_connection(ThousandIsland.Socket.t(), state :: term()) ::
ThousandIsland.Handler.handler_result() | on_switch_handler()
def handle_connection(socket, state) do
case {state.http_1_enabled, state.http_2_enabled, alpn_protocol(socket), sniff_wire(socket)} do
{_, _, _, :likely_tls} ->
Logger.warning("Connection that looks like TLS received on a clear channel",
domain: [:bandit],
plug: state.plug
)
{:close, state}
{_, true, Bandit.HTTP2.Handler, Bandit.HTTP2.Handler} ->
{:switch, Bandit.HTTP2.Handler, state}
{true, _, Bandit.HTTP1.Handler, {:no_match, data}} ->
{:switch, Bandit.HTTP1.Handler, data, state}
{_, true, :no_match, Bandit.HTTP2.Handler} ->
{:switch, Bandit.HTTP2.Handler, state}
{true, _, :no_match, {:no_match, data}} ->
{:switch, Bandit.HTTP1.Handler, data, state}
_other ->
{:close, state}
end
end
# Returns the protocol as negotiated via ALPN, if applicable
@spec alpn_protocol(ThousandIsland.Socket.t()) ::
Bandit.HTTP2.Handler | Bandit.HTTP1.Handler | :no_match
defp alpn_protocol(socket) do
case ThousandIsland.Socket.negotiated_protocol(socket) do
{:ok, "h2"} -> Bandit.HTTP2.Handler
{:ok, "http/1.1"} -> Bandit.HTTP1.Handler
_ -> :no_match
end
end
# Returns the protocol as suggested by received data, if possible.
# We do this in two phases so that we don't hang on *really* short HTTP/1
# requests that are less than 24 bytes
@spec sniff_wire(ThousandIsland.Socket.t()) ::
Bandit.HTTP2.Handler
| :likely_tls
| {:no_match, binary()}
| {:error, :closed | :timeout | :inet.posix()}
defp sniff_wire(socket) do
case ThousandIsland.Socket.recv(socket, 3) do
{:ok, "PRI" = buffer} -> sniff_wire_for_http2(socket, buffer)
{:ok, <<22::8, 3::8, minor::8>>} when minor in [1, 3] -> :likely_tls
{:ok, data} -> {:no_match, data}
{:error, :timeout} -> {:no_match, <<>>}
{:error, error} -> {:error, error}
end
end
defp sniff_wire_for_http2(socket, buffer) do
case ThousandIsland.Socket.recv(socket, 21) do
{:ok, " * HTTP/2.0\r\n\r\nSM\r\n\r\n"} -> Bandit.HTTP2.Handler
{:ok, data} -> {:no_match, buffer <> data}
{:error, :timeout} -> {:no_match, buffer}
{:error, error} -> {:error, error}
end
end
end

View File

@@ -0,0 +1,46 @@
defmodule Bandit.Logger do
@moduledoc false
require Logger
def maybe_log_protocol_error(error, stacktrace, opts, metadata) do
logging_verbosity =
case error do
%Bandit.TransportError{error: :closed} ->
Keyword.get(opts.http, :log_client_closures, false)
_error ->
Keyword.get(opts.http, :log_protocol_errors, :short)
end
case logging_verbosity do
:short ->
logger_metadata = logger_metadata_for(:error, error, stacktrace, metadata)
Logger.error(Exception.format_banner(:error, error, stacktrace), logger_metadata)
:verbose ->
logger_metadata = logger_metadata_for(:error, error, stacktrace, metadata)
Logger.error(Exception.format(:error, error, stacktrace), logger_metadata)
false ->
:ok
end
end
def logger_metadata_for(kind, reason, stacktrace, metadata) do
crash_reason = crash_reason(kind, reason, stacktrace)
case reason do
%Bandit.HTTP2.Errors.StreamError{stream_id: stream_id} when is_integer(stream_id) ->
[stream_id: stream_id, domain: [:bandit], crash_reason: crash_reason]
|> Keyword.merge(metadata)
_ ->
[domain: [:bandit], crash_reason: crash_reason]
|> Keyword.merge(metadata)
end
end
defp crash_reason(:throw, reason, stacktrace), do: {{:nocatch, reason}, stacktrace}
defp crash_reason(_, reason, stacktrace), do: {reason, stacktrace}
end

View File

@@ -0,0 +1,116 @@
defmodule Bandit.PhoenixAdapter do
@moduledoc """
A Bandit adapter for Phoenix.
This adapter provides out-of-the-box support for all aspects of Phoenix 1.7 and later. Earlier
versions of Phoenix will work with this adapter, but without support for WebSockets.
To use this adapter, your project will need to include Bandit as a dependency:
```elixir
{:bandit, "~> 1.0"}
```
Once Bandit is included as a dependency of your Phoenix project, add the following `adapter:`
line to your endpoint configuration in `config/config.exs`, as in the following example:
```
# config/config.exs
config :your_app, YourAppWeb.Endpoint,
adapter: Bandit.PhoenixAdapter, # <---- ADD THIS LINE
url: [host: "localhost"],
render_errors: ...
```
That's it! **After restarting Phoenix you should see the startup message indicate that it is being
served by Bandit**, and everything should 'just work'. Note that if you have set any exotic
configuration options within your endpoint, you may need to update that configuration to work
with Bandit; see below for details.
## Endpoint configuration
This adapter supports the standard Phoenix structure for endpoint configuration. Top-level keys for
`:http` and `:https` are supported, and configuration values within each of those are interpreted
as raw Bandit configuration as specified by `t:Bandit.options/0`. Bandit's configuration supports
all values used in a standard out-of-the-box Phoenix application, so if you haven't made any
substantial changes to your endpoint configuration things should 'just work' for you.
In the event that you *have* made advanced changes to your endpoint configuration, you may need
to update this config to work with Bandit. Consult Bandit's documentation at
`t:Bandit.options/0` for details.
It can be difficult to know exactly *where* to put the options that you may need to set from the
ones available at `t:Bandit.options/0`. The general idea is that anything inside the `http:` or
`https:` keyword lists in your configuration are passed directly to `Bandit.start_link/1`, so an
example may look like so:
```elixir
# config/{dev,prod,etc}.exs
config :your_app, YourAppWeb.Endpoint,
http: [
ip: {127, 0, 0, 1},
port: 4000,
thousand_island_options: [num_acceptors: 123],
http_options: [log_protocol_errors: false],
http_1_options: [max_requests: 1],
websocket_options: [compress: false]
],
```
Note that, unlike the `adapter: Bandit.PhoenixAdapter` configuration change outlined previously,
configuration of specific `http:` and `https:` values is done on a per-environment basis in
Phoenix, so these changes will typically be in your `config/dev.exs`, `config/prod.exs` and
similar files.
"""
@doc """
Returns the Bandit server process for the provided scheme within the given Phoenix Endpoint
"""
@spec bandit_pid(module()) ::
{:ok, Supervisor.child() | :restarting | :undefined} | {:error, :no_server_found}
def bandit_pid(endpoint, scheme \\ :http) do
endpoint
|> Supervisor.which_children()
|> Enum.find(fn {id, _, _, _} -> id == {endpoint, scheme} end)
|> case do
{_, pid, _, _} -> {:ok, pid}
nil -> {:error, :no_server_found}
end
end
@doc """
Returns the bound address and port of the Bandit server process for the provided
scheme within the given Phoenix Endpoint
"""
def server_info(endpoint, scheme) do
case bandit_pid(endpoint, scheme) do
{:ok, pid} -> ThousandIsland.listener_info(pid)
{:error, reason} -> {:error, reason}
end
end
@doc false
def child_specs(endpoint, config) do
otp_app = Keyword.fetch!(config, :otp_app)
plug = resolve_plug(config[:code_reloader], endpoint)
for scheme <- [:http, :https], opts = config[scheme] do
([plug: plug, display_plug: endpoint, scheme: scheme, otp_app: otp_app] ++ opts)
|> Bandit.child_spec()
|> Supervisor.child_spec(id: {endpoint, scheme})
end
end
defp resolve_plug(code_reload?, endpoint) do
if code_reload? &&
Code.ensure_loaded?(Phoenix.Endpoint.SyncCodeReloadPlug) &&
function_exported?(Phoenix.Endpoint.SyncCodeReloadPlug, :call, 2) do
{Phoenix.Endpoint.SyncCodeReloadPlug, {endpoint, []}}
else
endpoint
end
end
end

View File

@@ -0,0 +1,248 @@
defmodule Bandit.Pipeline do
@moduledoc false
# Provides a common pipeline for HTTP/1.1 and h2 adapters, factoring together shared
# functionality relating to `Plug.Conn` management
@type plug_def :: {function() | module(), Plug.opts()}
@type conn_data :: {boolean(), :inet.ip_address()}
@type request_target ::
{scheme(), nil | Plug.Conn.host(), nil | Plug.Conn.port_number(), path()}
@type scheme :: String.t() | nil
@type path :: String.t() | :*
require Logger
@spec run(
Bandit.HTTPTransport.t(),
plug_def(),
ThousandIsland.Telemetry.t() | Bandit.Telemetry.t(),
conn_data(),
map()
) ::
{:ok, Bandit.HTTPTransport.t()}
| {:upgrade, Bandit.HTTPTransport.t(), :websocket, tuple()}
| {:error, term()}
def run(transport, plug, connection_span, conn_data, opts) do
measurements = %{monotonic_time: Bandit.Telemetry.monotonic_time()}
metadata = %{
connection_telemetry_span_context: connection_span.telemetry_span_context,
plug: plug
}
try do
{:ok, method, request_target, headers, transport} =
Bandit.HTTPTransport.read_headers(transport)
conn = build_conn!(transport, method, request_target, headers, conn_data, opts)
span = Bandit.Telemetry.start_span(:request, measurements, Map.put(metadata, :conn, conn))
try do
conn
|> call_plug!(plug)
|> maybe_upgrade!()
|> case do
{:no_upgrade, conn} ->
%Plug.Conn{adapter: {_mod, adapter}} = conn = commit_response!(conn)
Bandit.Telemetry.stop_span(span, adapter.metrics, %{conn: conn})
{:ok, adapter.transport}
{:upgrade, %Plug.Conn{adapter: {_mod, adapter}} = conn, protocol, opts} ->
conn = Plug.Conn.put_status(conn, 101)
Bandit.Telemetry.stop_span(span, adapter.metrics, %{conn: conn})
{:upgrade, adapter.transport, protocol, opts}
end
catch
kind, value ->
handle_error(kind, value, __STACKTRACE__, transport, span, opts, plug: plug, conn: conn)
end
rescue
exception ->
span = Bandit.Telemetry.start_span(:request, measurements, metadata)
handle_error(:error, exception, __STACKTRACE__, transport, span, opts, plug: plug)
end
end
@spec build_conn!(
Bandit.HTTPTransport.t(),
Plug.Conn.method(),
request_target(),
Plug.Conn.headers(),
conn_data(),
map()
) :: Plug.Conn.t()
defp build_conn!(transport, method, request_target, headers, {secure?, peer_address}, opts) do
adapter = Bandit.Adapter.init(self(), transport, method, headers, opts)
scheme = determine_scheme(secure?, request_target)
version = Bandit.HTTPTransport.version(transport)
{host, port} = determine_host_and_port!(scheme, version, request_target, headers)
{path, query} = determine_path_and_query(request_target)
uri = %URI{scheme: scheme, host: host, port: port, path: path, query: query}
Plug.Conn.Adapter.conn({Bandit.Adapter, adapter}, method, uri, peer_address, headers)
end
@spec determine_scheme(boolean(), request_target()) :: String.t() | nil
defp determine_scheme(secure?, {scheme, _, _, _}) do
case {secure?, scheme} do
{true, nil} -> "https"
{false, nil} -> "http"
{_, scheme} -> scheme
end
end
@spec determine_host_and_port!(binary(), atom(), request_target(), Plug.Conn.headers()) ::
{Plug.Conn.host(), Plug.Conn.port_number()}
defp determine_host_and_port!(scheme, version, {_, nil, nil, _}, headers) do
case {Bandit.Headers.get_header(headers, "host"), version} do
{nil, :"HTTP/1.0"} ->
{"", URI.default_port(scheme)}
{nil, _} ->
request_error!("Unable to obtain host and port: No host header")
{host_header, _} ->
{host, port} = Bandit.Headers.parse_hostlike_header!(host_header)
{host, port || URI.default_port(scheme)}
end
end
defp determine_host_and_port!(scheme, _version, {_, host, port, _}, _headers),
do: {to_string(host), port || URI.default_port(scheme)}
@spec determine_path_and_query(request_target()) :: {String.t(), nil | String.t()}
defp determine_path_and_query({_, _, _, :*}), do: {"*", nil}
defp determine_path_and_query({_, _, _, path}), do: split_path(path)
@spec split_path(String.t()) :: {String.t(), nil | String.t()}
defp split_path(path) do
path
|> to_string()
|> :binary.split("#")
|> hd()
|> :binary.split("?")
|> case do
[path, query] -> {path, query}
[path] -> {path, nil}
end
end
@spec call_plug!(Plug.Conn.t(), plug_def()) :: Plug.Conn.t() | no_return()
defp call_plug!(%Plug.Conn{} = conn, {plug, plug_opts}) when is_atom(plug) do
case plug.call(conn, plug_opts) do
%Plug.Conn{} = conn -> conn
other -> raise("Expected #{plug}.call/2 to return %Plug.Conn{} but got: #{inspect(other)}")
end
end
defp call_plug!(%Plug.Conn{} = conn, {plug_fn, plug_opts}) when is_function(plug_fn) do
case plug_fn.(conn, plug_opts) do
%Plug.Conn{} = conn -> conn
other -> raise("Expected Plug function to return %Plug.Conn{} but got: #{inspect(other)}")
end
end
@spec maybe_upgrade!(Plug.Conn.t()) ::
{:no_upgrade, Plug.Conn.t()} | {:upgrade, Plug.Conn.t(), :websocket, tuple()}
defp maybe_upgrade!(
%Plug.Conn{
state: :upgraded,
adapter:
{_,
%{upgrade: {:websocket, {websock, websock_opts, connection_opts}, websocket_opts}}}
} = conn
) do
# We can safely unset the state, since we match on :upgraded above
case Bandit.WebSocket.Handshake.handshake(
%{conn | state: :unset},
connection_opts,
websocket_opts
) do
{:ok, conn, connection_opts} ->
{:upgrade, conn, :websocket, {websock, websock_opts, connection_opts}}
{:error, reason} ->
request_error!(reason)
end
end
defp maybe_upgrade!(conn), do: {:no_upgrade, conn}
@spec commit_response!(Plug.Conn.t()) :: Plug.Conn.t() | no_return()
defp commit_response!(conn) do
case conn do
%Plug.Conn{state: :unset} ->
raise(Plug.Conn.NotSentError)
%Plug.Conn{state: :set} ->
Plug.Conn.send_resp(conn)
%Plug.Conn{state: :chunked, adapter: {mod, adapter}} ->
adapter =
case mod.chunk(adapter, "") do
{:ok, _, adapter} -> adapter
_ -> adapter
end
%{conn | adapter: {mod, adapter}}
%Plug.Conn{} ->
conn
end
|> then(fn %Plug.Conn{adapter: {mod, adapter}} = conn ->
transport = Bandit.HTTPTransport.ensure_completed(adapter.transport)
%{conn | adapter: {mod, %{adapter | transport: transport}}}
end)
end
@spec request_error!(term()) :: no_return()
@spec request_error!(term(), Plug.Conn.status()) :: no_return()
defp request_error!(reason, plug_status \\ :bad_request) do
raise Bandit.HTTPError, message: reason, plug_status: plug_status
end
@spec handle_error(
:error | :throw | :exit,
Exception.t() | term(),
Exception.stacktrace(),
Bandit.HTTPTransport.t(),
Bandit.Telemetry.t(),
map(),
keyword()
) :: {:ok, Bandit.HTTPTransport.t()} | {:error, term()}
defp handle_error(:error, %Plug.Conn.WrapperError{} = error, _, transport, span, opts, metadata) do
# Unwrap the inner error and handle it
handle_error(error.kind, error.reason, error.stack, transport, span, opts, metadata)
end
defp handle_error(:error, %type{} = error, stacktrace, transport, span, opts, metadata)
when type in [
Bandit.HTTPError,
Bandit.TransportError,
Bandit.HTTP2.Errors.StreamError,
Bandit.HTTP2.Errors.ConnectionError
] do
Bandit.Telemetry.stop_span(span, %{}, Enum.into(metadata, %{error: error.message}))
Bandit.Logger.maybe_log_protocol_error(error, stacktrace, opts, metadata)
# We want to do this at the end of the function, since the HTTP2 stack may kill this process
# in the course of handling a ConnectionError
Bandit.HTTPTransport.send_on_error(transport, error)
{:error, error}
end
defp handle_error(kind, reason, stacktrace, transport, span, opts, metadata) do
reason = Exception.normalize(kind, reason, stacktrace)
Bandit.Telemetry.span_exception(span, kind, reason, stacktrace)
status = reason |> Plug.Exception.status() |> Plug.Conn.Status.code()
if status in Keyword.get(opts.http, :log_exceptions_with_status_codes, 500..599) do
logger_metadata = Bandit.Logger.logger_metadata_for(kind, reason, stacktrace, metadata)
Logger.error(Exception.format(kind, reason, stacktrace), logger_metadata)
end
Bandit.HTTPTransport.send_on_error(transport, reason)
{:error, reason}
end
end

View File

@@ -0,0 +1,34 @@
defmodule Bandit.PrimitiveOps.WebSocket do
@moduledoc """
WebSocket primitive operations behaviour and default implementation
"""
@doc """
WebSocket masking according to [RFC6455§5.3](https://www.rfc-editor.org/rfc/rfc6455#section-5.3)
"""
@callback ws_mask(payload :: binary(), mask :: integer()) :: binary()
@behaviour __MODULE__
# Note that masking is an involution, so we don't need a separate unmask function
@impl true
def ws_mask(payload, mask)
when is_binary(payload) and is_integer(mask) and mask >= 0x00000000 and mask <= 0xFFFFFFFF do
ws_mask(<<>>, payload, mask)
end
defp ws_mask(acc, <<h::32, rest::binary>>, mask) do
ws_mask(<<acc::binary, (<<Bitwise.bxor(h, mask)::32>>)>>, rest, mask)
end
for size <- [24, 16, 8] do
defp ws_mask(acc, <<h::unquote(size)>>, mask) do
<<mask::unquote(size), _::binary>> = <<mask::32>>
<<acc::binary, (<<Bitwise.bxor(h, mask)::unquote(size)>>)>>
end
end
defp ws_mask(acc, <<>>, _mask) do
acc
end
end

View File

@@ -0,0 +1,74 @@
defmodule Bandit.SocketHelpers do
@moduledoc false
def iodata_empty?(""), do: true
def iodata_empty?([]), do: true
def iodata_empty?([head | tail]), do: iodata_empty?(head) and iodata_empty?(tail)
def iodata_empty?(_), do: false
@spec conn_data(ThousandIsland.Socket.t()) :: Bandit.Pipeline.conn_data()
def conn_data(socket) do
secure? = ThousandIsland.Socket.secure?(socket)
{peer_address, _port} =
case ThousandIsland.Socket.peername(socket) do
{:ok, peername} -> map_address(peername)
{:error, reason} -> transport_error!("Unable to obtain conn_data", reason)
end
{secure?, peer_address}
end
@spec peer_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.peer_data()
def peer_data(socket) do
with {:ok, peername} <- ThousandIsland.Socket.peername(socket),
{address, port} <- map_address(peername),
{:ok, ssl_cert} <- peercert(socket) do
%{address: address, port: port, ssl_cert: ssl_cert}
else
{:error, reason} -> transport_error!("Unable to obtain peer_data", reason)
end
end
@spec sock_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.sock_data()
def sock_data(socket) do
with {:ok, sockname} <- ThousandIsland.Socket.sockname(socket),
{address, port} <- map_address(sockname) do
%{address: address, port: port}
else
{:error, reason} -> transport_error!("Unable to obtain sock_data", reason)
end
end
@spec ssl_data(ThousandIsland.Socket.t()) :: Plug.Conn.Adapter.ssl_data()
def ssl_data(socket) do
case ThousandIsland.Socket.connection_information(socket) do
{:ok, connection_information} -> connection_information
{:error, :not_secure} -> nil
{:error, reason} -> transport_error!("Unable to obtain ssl_data", reason)
end
end
defp map_address(address) do
case address do
{:local, path} -> {{:local, path}, 0}
{:unspec, <<>>} -> {:unspec, 0}
{:undefined, term} -> {{:undefined, term}, 0}
{ip, port} -> {ip, port}
end
end
defp peercert(socket) do
case ThousandIsland.Socket.peercert(socket) do
{:ok, cert} -> {:ok, cert}
{:error, :no_peercert} -> {:ok, nil}
{:error, :not_secure} -> {:ok, nil}
{:error, reason} -> {:error, reason}
end
end
@spec transport_error!(term(), term()) :: no_return()
defp transport_error!(message, error) do
raise Bandit.TransportError, message: message, error: error
end
end

View File

@@ -0,0 +1,250 @@
defmodule Bandit.Telemetry do
@moduledoc """
The following telemetry spans are emitted by bandit
## `[:bandit, :request, *]`
Represents Bandit handling a specific client HTTP request
This span is started by the following event:
* `[:bandit, :request, :start]`
Represents the start of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
This span is ended by the following event:
* `[:bandit, :request, :stop]`
Represents the end of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `duration`: The span duration, in `:native` units
* `req_header_end_time`: The time that header reading completed, in `:native` units
* `req_body_start_time`: The time that request body reading started, in `:native` units.
* `req_body_end_time`: The time that request body reading completed, in `:native` units
* `req_body_bytes`: The length of the request body, in octets
* `resp_start_time`: The time that the response started, in `:native` units
* `resp_end_time`: The time that the response completed, in `:native` units
* `resp_body_bytes`: The length of the response body, in octets. If the response is
compressed, this is the size of the compressed payload as sent on the wire
* `resp_uncompressed_body_bytes`: The length of the original, uncompressed body. Only
included for responses which are compressed
* `resp_compression_method`: The method of compression, as sent in the `Content-Encoding`
header of the response. Only included for responses which are compressed
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
* `error`: The error that caused the span to end, if it ended in error
The following events may be emitted within this span:
* `[:bandit, :request, :exception]`
The request for this span ended unexpectedly
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `conn`: The `Plug.Conn` representing this connection. Not present in cases where `error`
is also set and the nature of error is such that Bandit was unable to successfully build
the conn
* `plug`: The Plug which is being used to serve this request. Specified as `{plug_module, plug_opts}`
* `kind`: The kind of unexpected condition, typically `:exit`
* `exception`: The exception which caused this unexpected termination. May be an exception
or an arbitrary value when the event was an uncaught throw or an exit
* `stacktrace`: The stacktrace of the location which caused this unexpected termination
## `[:bandit, :websocket, *]`
Represents Bandit handling a WebSocket connection
This span is started by the following event:
* `[:bandit, :websocket, :start]`
Represents the start of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `compress`: Details about the compression configuration for this connection
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `websock`: The WebSock which is being used to serve this request. Specified as `websock_module`
This span is ended by the following event:
* `[:bandit, :websocket, :stop]`
Represents the end of the span
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `duration`: The span duration, in `:native` units
* `recv_text_frame_count`: The number of text frames received
* `recv_text_frame_bytes`: The total number of bytes received in the payload of text frames
* `recv_binary_frame_count`: The number of binary frames received
* `recv_binary_frame_bytes`: The total number of bytes received in the payload of binary frames
* `recv_ping_frame_count`: The number of ping frames received
* `recv_ping_frame_bytes`: The total number of bytes received in the payload of ping frames
* `recv_pong_frame_count`: The number of pong frames received
* `recv_pong_frame_bytes`: The total number of bytes received in the payload of pong frames
* `recv_connection_close_frame_count`: The number of connection close frames received
* `recv_connection_close_frame_bytes`: The total number of bytes received in the payload of connection close frames
* `recv_continuation_frame_count`: The number of continuation frames received
* `recv_continuation_frame_bytes`: The total number of bytes received in the payload of continuation frames
* `send_text_frame_count`: The number of text frames sent
* `send_text_frame_bytes`: The total number of bytes sent in the payload of text frames
* `send_binary_frame_count`: The number of binary frames sent
* `send_binary_frame_bytes`: The total number of bytes sent in the payload of binary frames
* `send_ping_frame_count`: The number of ping frames sent
* `send_ping_frame_bytes`: The total number of bytes sent in the payload of ping frames
* `send_pong_frame_count`: The number of pong frames sent
* `send_pong_frame_bytes`: The total number of bytes sent in the payload of pong frames
* `send_connection_close_frame_count`: The number of connection close frames sent
* `send_connection_close_frame_bytes`: The total number of bytes sent in the payload of connection close frames
* `send_continuation_frame_count`: The number of continuation frames sent
* `send_continuation_frame_bytes`: The total number of bytes sent in the payload of continuation frames
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `origin_telemetry_span_context`: The span context of the Bandit `:request` span from which
this connection originated
* `connection_telemetry_span_context`: The span context of the Thousand Island `:connection`
span which contains this request
* `websock`: The WebSock which is being used to serve this request. Specified as `websock_module`
* `error`: The error that caused the span to end, if it ended in error
"""
defstruct span_name: nil, telemetry_span_context: nil, start_time: nil, start_metadata: nil
@typep span_name :: atom()
@opaque t :: %__MODULE__{
span_name: span_name(),
telemetry_span_context: reference(),
start_time: integer(),
start_metadata: :telemetry.event_metadata()
}
@app_name :bandit
@doc false
@spec start_span(span_name(), :telemetry.event_measurements(), :telemetry.event_metadata()) ::
t()
def start_span(span_name, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
telemetry_span_context = make_ref()
metadata = Map.put(metadata, :telemetry_span_context, telemetry_span_context)
event([span_name, :start], measurements, metadata)
%__MODULE__{
span_name: span_name,
telemetry_span_context: telemetry_span_context,
start_time: measurements[:monotonic_time],
start_metadata: metadata
}
end
@doc false
@spec stop_span(t(), :telemetry.event_measurements(), :telemetry.event_metadata()) :: :ok
def stop_span(span, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
measurements =
Map.put(measurements, :duration, measurements[:monotonic_time] - span.start_time)
metadata = Map.merge(span.start_metadata, metadata)
untimed_span_event(span, :stop, measurements, metadata)
end
@spec span_exception(t(), Exception.kind(), Exception.t() | term(), Exception.stacktrace()) ::
:ok
def span_exception(span, kind, reason, stacktrace) do
# Using :exit for backwards-compatibility with Bandit =< 1.5.7
kind = if kind == :error, do: :exit, else: kind
metadata =
Map.merge(span.start_metadata, %{
kind: kind,
exception: reason,
stacktrace: stacktrace
})
span_event(span, :exception, %{}, metadata)
end
@doc false
@spec span_event(t(), span_name(), :telemetry.event_measurements(), :telemetry.event_metadata()) ::
:ok
def span_event(span, name, measurements \\ %{}, metadata \\ %{}) do
measurements = Map.put_new_lazy(measurements, :monotonic_time, &monotonic_time/0)
untimed_span_event(span, name, measurements, metadata)
end
@doc false
@spec untimed_span_event(
t(),
span_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata()
) :: :ok
def untimed_span_event(span, name, measurements \\ %{}, metadata \\ %{}) do
metadata = Map.put(metadata, :telemetry_span_context, span.telemetry_span_context)
event([span.span_name, name], measurements, metadata)
end
@spec monotonic_time :: integer()
defdelegate monotonic_time, to: System
@spec event(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata()
) :: :ok
defp event(suffix, measurements, metadata) do
:telemetry.execute([@app_name | suffix], measurements, metadata)
end
@doc false
@spec telemetry_span_context(t()) :: reference()
def telemetry_span_context(span) do
span.telemetry_span_context
end
end

View File

@@ -0,0 +1,150 @@
defmodule Bandit.Trace do
@moduledoc """
**THIS MODULE IS EXPERIMENTAL AND SUBJECT TO CHANGE**
Helper functions to provide visibility into runtime errors within a running Bandit instance
Can be used within an IEx session attached to a running Bandit instance, as follows:
```
iex> Bandit.Trace.start_tracing()
... # Wait for traces to show up whenever exceptions are raised
iex> Bandit.Trace.stop_tracing()
```
It can also be started within your application by adding `Bandit.Trace` to your process tree.
`Bandit.Trace` will emit a trace on every exception that Bandit sees (both those emitted from
within your Plug as well as internal ones due to protocol violations and the like). These traces
consist of a complete dump of all telemetry events that occur in the offending request's parent
connection.
Tracing imposes a modest but non-zero load; it *should* be safe to run in most production
environments, but it is not intended to run on an ongoing basis.
By default, `Bandit.Trace` maintains a FIFO log of the last 10000 telemetry events that Bandit
has emitted. Events which correlate to the parent connection which have been evicted from this
queue will not be included in this output.
**WARNING** The emitted logs contains a *complete* copy of your request's Plug data, as well as *all* data
sent and received on all requests which are contained in the output. It is therefore of the utmost
importance that you carefully redact the output before sharing it publicly.
"""
defstruct queue: nil, size: 0, max_size: 10_000, trace_on_exception: true
use GenServer
@events [
[:bandit, :request, :start],
[:bandit, :request, :stop],
[:bandit, :request, :exception],
[:bandit, :websocket, :start],
[:bandit, :websocket, :stop],
[:thousand_island, :connection, :start],
[:thousand_island, :connection, :stop],
[:thousand_island, :connection, :ready],
[:thousand_island, :connection, :async_recv],
[:thousand_island, :connection, :recv],
[:thousand_island, :connection, :recv_error],
[:thousand_island, :connection, :send],
[:thousand_island, :connection, :send_error],
[:thousand_island, :connection, :sendfile],
[:thousand_island, :connection, :sendfile_error],
[:thousand_island, :connection, :socket_shutdown]
]
@doc """
Start tracing of all Bandit requests
See module documentation for intended usage. Accepts the following options:
* `max_size`: The size of the telemetry event queue to maintain. By default, `Bandit.Trace` maintains a
queue of the last 10000 telemetry events
* `trace_on_exception`: Whether or not to emit traces when an error is raised within
Bandit. Defaults to `true`
"""
def start_tracing(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@doc """
Stop any active trace session
"""
def stop_tracing, do: GenServer.stop(__MODULE__)
def handle_event(event, measurements, metadata, pid),
do: GenServer.cast(pid, {:event, {event, measurements, metadata, :os.perf_counter()}})
@doc """
Return the complete queue of telemetry events that `Bandit.Trace` is currently tracking
"""
def get_events, do: GenServer.call(__MODULE__, :get_events)
@impl GenServer
def init(opts) do
_ = :telemetry.attach_many(self(), @events, &__MODULE__.handle_event/4, self())
{:ok, struct!(%__MODULE__{queue: :queue.new()}, opts)}
end
@impl GenServer
def terminate(_, _), do: :telemetry.detach(self())
@impl GenServer
def handle_cast({:event, event}, state) do
state
|> maybe_pop()
|> push(event)
|> tap(&maybe_trace(&1, event))
|> then(&{:noreply, &1})
end
defp maybe_pop(%{size: size, max_size: max_size} = state) when size >= max_size,
do: maybe_pop(%{state | queue: :queue.drop(state.queue), size: size - 1})
defp maybe_pop(state), do: state
defp push(state, event),
do: %{state | queue: :queue.in(event, state.queue), size: state.size + 1}
defp maybe_trace(
%{trace_on_exception: true} = state,
{[:bandit, :request, :exception], _, metadata, _}
) do
connection_span_context = Map.get(metadata, :connection_telemetry_span_context)
IO.puts("======================================")
IO.puts("Starting telemetry trace for exception")
IO.puts("======================================")
:queue.to_list(state.queue)
|> Enum.filter(fn {_, _, metadata, _} ->
Map.get(metadata, :telemetry_span_context) == connection_span_context ||
Map.get(metadata, :connection_telemetry_span_context) == connection_span_context
end)
|> format_list()
|> inspect(limit: :infinity, pretty: true, printable_limit: :infinity)
|> IO.puts()
IO.puts("=======================================")
IO.puts("Completed telemetry trace for exception")
IO.puts("=======================================")
:ok
end
defp maybe_trace(_state, _event), do: :ok
@impl GenServer
def handle_call(:get_events, _from, state),
do: {:reply, :queue.to_list(state.queue) |> format_list(), state}
defp format_list([]), do: :ok
defp format_list([{_, _, _, start_time} | _] = events),
do: Enum.map(events, &format_tuple(&1, start_time))
defp format_tuple({event, measurements, metadata, time}, start_time) do
time = :erlang.convert_time_unit(time - start_time, :perf_counter, :microsecond)
%{telemetry_span_context: span_id} = metadata
{time, span_id, event, measurements, metadata}
end
end

View File

@@ -0,0 +1,6 @@
defmodule Bandit.TransportError do
# Represents an error coming from the underlying transport which cannot be signalled back to the
# client by conventional means within the request. Examples include TCP socket closures and
# errors in the case of HTTP/1, and stream resets in HTTP/2
defexception message: nil, error: nil
end

View File

@@ -0,0 +1,58 @@
# WebSocket Handler
Included in this folder is a complete `ThousandIsland.Handler` based implementation of WebSockets
as defined in [RFC 6455](https://datatracker.ietf.org/doc/rfc6455).
## Upgrade mechanism
A good overview of this process is contained in this [ElixirConf EU
talk](https://www.youtube.com/watch?v=usKLrYl4zlY).
Upgrading an HTTP connection to a WebSocket connection is coordinated by code
contained within several libraries, including Bandit,
[WebSockAdapter](https://github.com/phoenixframework/websock_adapter), and
[Plug](https://github.com/elixir-plug/plug).
The HTTP request containing the upgrade request is first passed to the user's
application as a standard Plug call. After inspecting the request and deeming it
a suitable upgrade candidate (via whatever policy the application dictates), the
user indicates a desire to upgrade the connection to a WebSocket by calling
`WebSockAdapter.upgrade/4`, which checks that the request is a valid WebSocket
upgrade request, and then calls `Plug.Conn.upgrade_adapter/3` to signal to
Bandit that the connection should be upgraded at the conclusion of the request.
At the conclusion of the `Plug.call/2` callback, `Bandit.Pipeline` will then
attempt to upgrade the underlying connection. As part of this upgrade process,
`Bandit.DelegatingHandler` will switch the Handler for the connection to be
`Bandit.WebSocket.Handler`. This will cause any future communication after the
upgrade process to be handled directly by Bandit's WebSocket stack.
## Process model
Within a Bandit server, a WebSocket connection is modeled as a single process.
This process is directly tied to the lifecycle of the underlying WebSocket
connection; when upgrading from HTTP/1, the existing HTTP/1 handler process
'magically' becomes a WebSocket process by changing which Handler the
`Bandit.DelegatingHandler` delegates to.
The execution model to handle a given request is quite straightforward: at
upgrade time, the `Bandit.DelegatingHandler` will call `handle_connection/2` to
allow the WebSocket handler to initialize any startup state. Connection state is
modeled by the `Bandit.WebSocket.Connection` struct and module.
All data subsequently received by the underlying [Thousand
Island](https://github.com/mtrudel/thousand_island) library will result in
a call to `Bandit.WebSocket.Handler.handle_data/3`, which will then attempt to
parse the data into one or more WebSocket frames. Once a frame has been
constructed, it is them passed through to the configured `WebSock` handler by
way of the underlying `Bandit.WebSocket.Connection`.
# Testing
All of this is exhaustively tested. Tests are broken up primarily into `protocol_test.exs`, which
is concerned with aspects of the implementation relating to protocol conformance and
client-facing concerns, while `sock_test.exs` is concerned with aspects of the implementation
having to do with the WebSock API and application-facing concerns. There are also more
unit-style tests covering frame serialization and deserialization.
In addition, the `autobahn` conformance suite is run via a `System` wrapper & executes the entirety
of the suite against a running Bandit server.

View File

@@ -0,0 +1,315 @@
defmodule Bandit.WebSocket.Connection do
@moduledoc false
# Implementation of a WebSocket lifecycle, implemented using a Socket protocol for communication
alias Bandit.WebSocket.{Frame, PerMessageDeflate, Socket}
defstruct websock: nil,
websock_state: nil,
state: :open,
compress: nil,
opts: [],
fragment_frame: nil,
span: nil,
metrics: %{}
@typedoc "Connection state"
@type state :: :open | :closing | :closed
@typedoc "Encapsulates the state of a WebSocket connection"
@type t :: %__MODULE__{
websock: WebSock.impl(),
websock_state: WebSock.state(),
state: state(),
compress: PerMessageDeflate.t() | nil,
opts: keyword(),
fragment_frame: Frame.Text.t() | Frame.Binary.t() | nil,
span: Bandit.Telemetry.t(),
metrics: map()
}
def init(websock, websock_state, connection_opts, socket) do
compress = Keyword.get(connection_opts, :compress)
connection_telemetry_span_context =
ThousandIsland.Socket.telemetry_span(socket).telemetry_span_context
span =
Bandit.Telemetry.start_span(:websocket, %{compress: compress}, %{
connection_telemetry_span_context: connection_telemetry_span_context,
websock: websock
})
instance = %__MODULE__{
websock: websock,
websock_state: websock_state,
compress: compress,
opts: connection_opts,
span: span
}
websock.init(websock_state) |> handle_continutation(socket, instance)
end
def handle_frame(frame, socket, %{fragment_frame: nil} = connection) do
connection = do_recv_metrics(frame, connection)
case frame do
%Frame.Continuation{} ->
do_error(1002, "Received unexpected continuation frame (RFC6455§5.4)", socket, connection)
%Frame.Text{fin: true, compressed: true} = frame ->
do_inflate(frame, socket, connection)
%Frame.Text{fin: true} = frame ->
if !Keyword.get(connection.opts, :validate_text_frames, true) || String.valid?(frame.data) do
connection.websock.handle_in({frame.data, opcode: :text}, connection.websock_state)
|> handle_continutation(socket, connection)
else
do_error(1007, "Received non UTF-8 text frame (RFC6455§8.1)", socket, connection)
end
%Frame.Text{fin: false} = frame ->
{:continue, %{connection | fragment_frame: frame}}
%Frame.Binary{fin: true, compressed: true} = frame ->
do_inflate(frame, socket, connection)
%Frame.Binary{fin: true} = frame ->
connection.websock.handle_in({frame.data, opcode: :binary}, connection.websock_state)
|> handle_continutation(socket, connection)
%Frame.Binary{fin: false} = frame ->
{:continue, %{connection | fragment_frame: frame}}
frame ->
handle_control_frame(frame, socket, connection)
end
end
def handle_frame(frame, socket, %{fragment_frame: fragment_frame} = connection)
when not is_nil(fragment_frame) do
connection = do_recv_metrics(frame, connection)
case frame do
%Frame.Continuation{fin: true} = frame ->
data = IO.iodata_to_binary([connection.fragment_frame.data | frame.data])
frame = %{connection.fragment_frame | fin: true, data: data}
handle_frame(frame, socket, %{connection | fragment_frame: nil})
%Frame.Continuation{fin: false} = frame ->
data = [connection.fragment_frame.data | frame.data]
frame = %{connection.fragment_frame | fin: true, data: data}
{:continue, %{connection | fragment_frame: frame}}
%Frame.Text{} ->
do_error(1002, "Received unexpected text frame (RFC6455§5.4)", socket, connection)
%Frame.Binary{} ->
do_error(1002, "Received unexpected binary frame (RFC6455§5.4)", socket, connection)
frame ->
handle_control_frame(frame, socket, connection)
end
end
defp handle_control_frame(frame, socket, connection) do
case frame do
%Frame.ConnectionClose{} = frame ->
# This is a bit of a subtle case, see RFC6455§7.4.1-2
reply_code =
case frame.code do
code when code in 1000..1003 or code in 1007..1011 or code > 2999 -> 1000
_code -> 1002
end
{:continue, connection} = do_stop(reply_code, :remote, socket, connection)
{:close, %{connection | state: :closed, compress: nil}}
%Frame.Ping{} = frame ->
connection =
Socket.send_frame(socket, {:pong, frame.data}, false)
|> do_send_metrics(connection)
if function_exported?(connection.websock, :handle_control, 2) do
connection.websock.handle_control({frame.data, opcode: :ping}, connection.websock_state)
|> handle_continutation(socket, connection)
else
{:continue, connection}
end
%Frame.Pong{} = frame ->
if function_exported?(connection.websock, :handle_control, 2) do
connection.websock.handle_control({frame.data, opcode: :pong}, connection.websock_state)
|> handle_continutation(socket, connection)
else
{:continue, connection}
end
end
end
defp do_recv_metrics(frame, connection) do
metrics =
Bandit.WebSocket.Frame.recv_metrics(frame)
|> Enum.reduce(connection.metrics, fn {key, value}, metrics ->
Map.update(metrics, key, value, &(&1 + value))
end)
%{connection | metrics: metrics}
end
defp do_send_metrics(metrics, connection) do
metrics =
metrics
|> Enum.reduce(connection.metrics, fn {key, value}, metrics ->
Map.update(metrics, key, value, &(&1 + value))
end)
%{connection | metrics: metrics}
end
def handle_close(socket, connection), do: do_error(1006, :closed, socket, connection)
# Some uncertainty if this should be 1000 or 1001 @ https://github.com/mtrudel/bandit/issues/89
def handle_shutdown(socket, connection), do: do_stop(1000, :shutdown, socket, connection)
def handle_error({:deserializing, :max_frame_size_exceeded = reason}, socket, connection),
do: do_error(1009, reason, socket, connection)
def handle_error({:deserializing, reason}, socket, connection),
do: do_error(1002, reason, socket, connection)
def handle_error(reason, socket, connection), do: do_error(1011, reason, socket, connection)
def handle_timeout(socket, connection), do: do_error(1002, :timeout, socket, connection)
def handle_info(msg, socket, connection) do
connection.websock.handle_info(msg, connection.websock_state)
|> handle_continutation(socket, connection)
end
defp handle_continutation(continutation, socket, connection) do
case continutation do
{:ok, websock_state} ->
{:continue, %{connection | websock_state: websock_state}}
{:reply, _status, msg, websock_state} ->
do_deflate(msg, socket, %{connection | websock_state: websock_state})
{:push, msg, websock_state} ->
do_deflate(msg, socket, %{connection | websock_state: websock_state})
{:stop, :normal, websock_state} ->
do_stop(1000, :normal, socket, %{connection | websock_state: websock_state})
{:stop, :normal, code, websock_state} ->
do_stop(code, :normal, socket, %{connection | websock_state: websock_state})
{:stop, :normal, code, msg, websock_state} ->
case do_deflate(msg, socket, %{connection | websock_state: websock_state}) do
{:continue, connection} -> do_stop(code, :normal, socket, connection)
other -> other
end
{:stop, {:shutdown, :disconnected}, websock_state} ->
do_stop(1000, :normal, socket, %{connection | websock_state: websock_state})
{:stop, {:shutdown, :restart}, websock_state} ->
do_stop(1012, :normal, socket, %{connection | websock_state: websock_state})
{:stop, reason, websock_state} ->
do_error(1011, reason, socket, %{connection | websock_state: websock_state})
{:stop, reason, code, websock_state} ->
do_error(code, reason, socket, %{connection | websock_state: websock_state})
{:stop, reason, code, msg, websock_state} ->
case do_deflate(msg, socket, %{connection | websock_state: websock_state}) do
{:continue, connection} -> do_error(code, reason, socket, connection)
other -> other
end
end
end
defp do_stop(code, reason, socket, connection) do
if connection.state == :open do
if function_exported?(connection.websock, :terminate, 2) do
connection.websock.terminate(reason, connection.websock_state)
end
_ = Socket.close(socket, code)
if connection.compress, do: PerMessageDeflate.close(connection.compress)
Bandit.Telemetry.stop_span(connection.span, connection.metrics)
end
{:continue, %{connection | state: :closing, compress: nil}}
end
defp do_error(code, reason, socket, connection) do
if connection.state == :open do
if function_exported?(connection.websock, :terminate, 2) do
connection.websock.terminate(maybe_wrap_reason(reason), connection.websock_state)
end
_ = Socket.close(socket, code)
if connection.compress, do: PerMessageDeflate.close(connection.compress)
Bandit.Telemetry.stop_span(connection.span, connection.metrics, %{error: reason})
end
{:error, reason, %{connection | state: :closed, compress: nil}}
end
defp maybe_wrap_reason(:timeout), do: :timeout
defp maybe_wrap_reason(reason), do: {:error, reason}
defp do_deflate(msgs, socket, connection) when is_list(msgs) do
Enum.reduce(msgs, {:continue, connection}, fn
msg, {:continue, connection} -> do_deflate(msg, socket, connection)
_msg, other -> other
end)
end
defp do_deflate({opcode, data} = msg, socket, connection) when opcode in [:text, :binary] do
case PerMessageDeflate.deflate(data, connection.compress) do
{:ok, data, compress} ->
connection =
Socket.send_frame(socket, {opcode, data}, true)
|> do_send_metrics(connection)
{:continue, %{connection | compress: compress}}
{:error, :no_compress} ->
connection =
Socket.send_frame(socket, msg, false)
|> do_send_metrics(connection)
{:continue, connection}
{:error, reason} ->
do_error(1007, "Deflation error: #{inspect(reason)}", socket, connection)
end
end
defp do_deflate({opcode, _data} = msg, socket, connection) when opcode in [:ping, :pong] do
connection =
Socket.send_frame(socket, msg, false)
|> do_send_metrics(connection)
{:continue, connection}
end
defp do_inflate(frame, socket, connection) do
case PerMessageDeflate.inflate(frame.data, connection.compress) do
{:ok, data, compress} ->
frame = %{frame | data: data, compressed: false}
connection = %{connection | compress: compress}
handle_frame(frame, socket, connection)
{:error, :no_compress} ->
do_error(1002, "Received unexpected compressed frame (RFC6455§5.2)", socket, connection)
{:error, _reason} ->
do_error(1007, "Inflation error", socket, connection)
end
end
end

View File

@@ -0,0 +1,205 @@
defmodule Bandit.WebSocket.Frame do
@moduledoc false
alias Bandit.WebSocket.Frame
@behaviour Bandit.Extractor
@typedoc "Indicates an opcode"
@type opcode ::
(binary :: 0x2)
| (connection_close :: 0x8)
| (continuation :: 0x0)
| (ping :: 0x9)
| (pong :: 0xA)
| (text :: 0x1)
@typedoc "A valid WebSocket frame"
@type frame ::
Frame.Continuation.t()
| Frame.Text.t()
| Frame.Binary.t()
| Frame.ConnectionClose.t()
| Frame.Ping.t()
| Frame.Pong.t()
@impl Bandit.Extractor
@spec header_and_payload_length(binary(), non_neg_integer()) ::
{:ok, {header_length :: integer(), payload_length :: integer()}}
| {:error, :max_frame_size_exceeded | :client_frame_without_mask}
| :more
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, 127::7, length::64, _mask::32,
_rest::binary>>,
max_frame_size
) do
validate_max_frame_size(14, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, 126::7, length::16, _mask::32,
_rest::binary>>,
max_frame_size
) do
validate_max_frame_size(8, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 1::1, length::7, _mask::32,
_rest::binary>>,
max_frame_size
)
when length <= 125 do
validate_max_frame_size(6, length, max_frame_size)
end
def header_and_payload_length(
<<_fin::1, _compressed::1, _rsv::2, _opcode::4, 0::1, _rest::binary>>,
_max_frame_size
) do
{:error, :client_frame_without_mask}
end
def header_and_payload_length(_msg, _max_frame_size) do
:more
end
defp validate_max_frame_size(header_length, payload_length, max_frame_size) do
if max_frame_size != 0 and header_length + payload_length > max_frame_size do
{:error, :max_frame_size_exceeded}
else
{:ok, {header_length, payload_length}}
end
end
@impl Bandit.Extractor
@spec deserialize(binary(), module()) :: {:ok, frame()} | {:error, term()}
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, 127::7, length::64, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, 126::7, length::16, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(
<<fin::1, compressed::1, rsv::2, opcode::4, 1::1, length::7, mask::32,
payload::binary-size(length)>>,
primitive_ops_module
) do
to_frame(fin, compressed, rsv, opcode, mask, payload, primitive_ops_module)
end
def deserialize(_msg, _primitive_ops_module) do
{:error, :deserialization_failed}
end
def recv_metrics(%frame_type{} = frame) do
case frame_type do
Frame.Continuation ->
[
recv_continuation_frame_count: 1,
recv_continuation_frame_bytes: IO.iodata_length(frame.data)
]
Frame.Text ->
[recv_text_frame_count: 1, recv_text_frame_bytes: IO.iodata_length(frame.data)]
Frame.Binary ->
[recv_binary_frame_count: 1, recv_binary_frame_bytes: IO.iodata_length(frame.data)]
Frame.ConnectionClose ->
[
recv_connection_close_frame_count: 1,
recv_connection_close_frame_bytes: IO.iodata_length(frame.reason)
]
Frame.Ping ->
[recv_ping_frame_count: 1, recv_ping_frame_bytes: IO.iodata_length(frame.data)]
Frame.Pong ->
[recv_pong_frame_count: 1, recv_pong_frame_bytes: IO.iodata_length(frame.data)]
end
end
def send_metrics(%frame_type{} = frame) do
case frame_type do
Frame.Continuation ->
[
send_continuation_frame_count: 1,
send_continuation_frame_bytes: IO.iodata_length(frame.data)
]
Frame.Text ->
[send_text_frame_count: 1, send_text_frame_bytes: IO.iodata_length(frame.data)]
Frame.Binary ->
[send_binary_frame_count: 1, send_binary_frame_bytes: IO.iodata_length(frame.data)]
Frame.ConnectionClose ->
[
send_connection_close_frame_count: 1,
send_connection_close_frame_bytes: IO.iodata_length(frame.reason)
]
Frame.Ping ->
[send_ping_frame_count: 1, send_ping_frame_bytes: IO.iodata_length(frame.data)]
Frame.Pong ->
[send_pong_frame_count: 1, send_pong_frame_bytes: IO.iodata_length(frame.data)]
end
end
defp to_frame(_fin, _compressed, rsv, _opcode, _mask, _payload, _primitive_ops_module)
when rsv != 0x0 do
{:error, "Received unsupported RSV flags #{rsv}"}
end
defp to_frame(fin, compressed, 0x0, opcode, mask, payload, primitive_ops_module) do
fin = fin == 0x1
compressed = compressed == 0x1
unmasked_payload = primitive_ops_module.ws_mask(payload, mask)
opcode
|> case do
0x0 -> Frame.Continuation.deserialize(fin, compressed, unmasked_payload)
0x1 -> Frame.Text.deserialize(fin, compressed, unmasked_payload)
0x2 -> Frame.Binary.deserialize(fin, compressed, unmasked_payload)
0x8 -> Frame.ConnectionClose.deserialize(fin, compressed, unmasked_payload)
0x9 -> Frame.Ping.deserialize(fin, compressed, unmasked_payload)
0xA -> Frame.Pong.deserialize(fin, compressed, unmasked_payload)
unknown -> {:error, "unknown opcode #{unknown}"}
end
end
defprotocol Serializable do
@moduledoc false
@spec serialize(any()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(frame)
end
@spec serialize(frame()) :: iolist()
def serialize(frame) do
frame
|> Serializable.serialize()
|> Enum.map(fn {opcode, fin, compressed, payload} ->
fin = if fin, do: 0x1, else: 0x0
compressed = if compressed, do: 0x1, else: 0x0
mask_and_length = payload |> IO.iodata_length() |> mask_and_length()
[<<fin::1, compressed::1, 0x0::2, opcode::4>>, mask_and_length, payload]
end)
end
defp mask_and_length(length) when length <= 125, do: <<0::1, length::7>>
defp mask_and_length(length) when length <= 65_535, do: <<0::1, 126::7, length::16>>
defp mask_and_length(length), do: <<0::1, 127::7, length::64>>
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.WebSocket.Frame.Binary do
@moduledoc false
defstruct fin: nil, compressed: false, data: <<>>
@typedoc "A WebSocket binary frame"
@type t :: %__MODULE__{fin: boolean(), compressed: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()}
def deserialize(fin, compressed, payload) do
{:ok, %__MODULE__{fin: fin, compressed: compressed, data: payload}}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x2, frame.fin, frame.compressed, frame.data}]
end
end

View File

@@ -0,0 +1,49 @@
defmodule Bandit.WebSocket.Frame.ConnectionClose do
@moduledoc false
defstruct code: nil, reason: <<>>
@typedoc "A WebSocket status code, or none at all"
@type status_code :: non_neg_integer() | nil
@typedoc "A WebSocket connection close frame"
@type t :: %__MODULE__{code: status_code(), reason: binary()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<>>) do
{:ok, %__MODULE__{}}
end
def deserialize(true, false, <<code::16>>) do
{:ok, %__MODULE__{code: code}}
end
def deserialize(true, false, <<code::16, reason::binary>>) when byte_size(reason) <= 123 do
if String.valid?(reason) do
{:ok, %__MODULE__{code: code, reason: reason}}
else
{:error, "Received non UTF-8 connection close frame (RFC6455§5.5.1)"}
end
end
def deserialize(true, false, _payload) do
{:error, "Invalid connection close payload (RFC6455§5.5)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented connection close frame (RFC6455§5.5)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed connection close frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{code: nil}), do: [{0x8, true, false, <<>>}]
def serialize(%@for{reason: nil} = frame), do: [{0x8, true, false, <<frame.code::16>>}]
def serialize(%@for{} = frame), do: [{0x8, true, false, [<<frame.code::16>>, frame.reason]}]
end
end

View File

@@ -0,0 +1,24 @@
defmodule Bandit.WebSocket.Frame.Continuation do
@moduledoc false
defstruct fin: nil, data: <<>>
@typedoc "A WebSocket continuation frame"
@type t :: %__MODULE__{fin: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(fin, false, payload) do
{:ok, %__MODULE__{fin: fin, data: payload}}
end
def deserialize(_fin, true, _payload) do
{:error, "Cannot have a compressed continuation frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x0, frame.fin, false, frame.data}]
end
end

View File

@@ -0,0 +1,32 @@
defmodule Bandit.WebSocket.Frame.Ping do
@moduledoc false
defstruct data: <<>>
@typedoc "A WebSocket ping frame"
@type t :: %__MODULE__{data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<data::binary>>) when byte_size(data) <= 125 do
{:ok, %__MODULE__{data: data}}
end
def deserialize(true, false, _payload) do
{:error, "Invalid ping payload (RFC6455§5.5.2)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented ping frame (RFC6455§5.5.2)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed ping frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x9, true, false, frame.data}]
end
end

View File

@@ -0,0 +1,32 @@
defmodule Bandit.WebSocket.Frame.Pong do
@moduledoc false
defstruct data: <<>>
@typedoc "A WebSocket pong frame"
@type t :: %__MODULE__{data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()} | {:error, term()}
def deserialize(true, false, <<data::binary>>) when byte_size(data) <= 125 do
{:ok, %__MODULE__{data: data}}
end
def deserialize(true, false, _payload) do
{:error, "Invalid pong payload (RFC6455§5.5.3)"}
end
def deserialize(false, false, _payload) do
{:error, "Cannot have a fragmented pong frame (RFC6455§5.5.3)"}
end
def deserialize(true, true, _payload) do
{:error, "Cannot have a compressed pong frame (RFC7692§6.1)"}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0xA, true, false, frame.data}]
end
end

View File

@@ -0,0 +1,20 @@
defmodule Bandit.WebSocket.Frame.Text do
@moduledoc false
defstruct fin: nil, compressed: false, data: <<>>
@typedoc "A WebSocket text frame"
@type t :: %__MODULE__{fin: boolean(), compressed: boolean(), data: iodata()}
@spec deserialize(boolean(), boolean(), iodata()) :: {:ok, t()}
def deserialize(fin, compressed, payload) do
{:ok, %__MODULE__{fin: fin, compressed: compressed, data: payload}}
end
defimpl Bandit.WebSocket.Frame.Serializable do
alias Bandit.WebSocket.Frame
@spec serialize(@for.t()) :: [{Frame.opcode(), boolean(), boolean(), iodata()}]
def serialize(%@for{} = frame), do: [{0x1, frame.fin, frame.compressed, frame.data}]
end
end

View File

@@ -0,0 +1,97 @@
defmodule Bandit.WebSocket.Handler do
@moduledoc false
# A WebSocket handler conforming to RFC6455, structured as a ThousandIsland.Handler
use ThousandIsland.Handler
alias Bandit.Extractor
alias Bandit.WebSocket.{Connection, Frame}
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
{websock, websock_opts, connection_opts} = state.upgrade_opts
connection_opts
|> Keyword.take([:fullsweep_after, :max_heap_size])
|> Enum.each(fn {key, value} -> :erlang.process_flag(key, value) end)
connection_opts = Keyword.merge(state.opts.websocket, connection_opts)
primitive_ops_module =
Keyword.get(state.opts.websocket, :primitive_ops_module, Bandit.PrimitiveOps.WebSocket)
state =
state
|> Map.take([:handler_module])
|> Map.put(:extractor, Extractor.new(Frame, primitive_ops_module, connection_opts))
case Connection.init(websock, websock_opts, connection_opts, socket) do
{:continue, connection} ->
case Keyword.get(connection_opts, :timeout) do
nil -> {:continue, Map.put(state, :connection, connection)}
timeout -> {:continue, Map.put(state, :connection, connection), {:persistent, timeout}}
end
{:error, reason, connection} ->
{:error, reason, Map.put(state, :connection, connection)}
end
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
state.extractor
|> Extractor.push_data(data)
|> pop_frame(socket, state)
end
defp pop_frame(extractor, socket, state) do
case Extractor.pop_frame(extractor) do
{extractor, {:ok, frame}} ->
case Connection.handle_frame(frame, socket, state.connection) do
{:continue, connection} ->
pop_frame(extractor, socket, %{state | extractor: extractor, connection: connection})
{:close, connection} ->
{:close, %{state | extractor: extractor, connection: connection}}
{:error, reason, connection} ->
{:error, reason, %{state | extractor: extractor, connection: connection}}
end
{extractor, {:error, reason}} ->
{:error, {:deserializing, reason}, %{state | extractor: extractor}}
{extractor, :more} ->
{:continue, %{state | extractor: extractor}}
end
end
@impl ThousandIsland.Handler
def handle_close(socket, %{connection: connection}),
do: Connection.handle_close(socket, connection)
def handle_close(_socket, _state), do: :ok
@impl ThousandIsland.Handler
def handle_shutdown(socket, state), do: Connection.handle_shutdown(socket, state.connection)
@impl ThousandIsland.Handler
def handle_error(reason, socket, state),
do: Connection.handle_error(reason, socket, state.connection)
@impl ThousandIsland.Handler
def handle_timeout(socket, state), do: Connection.handle_timeout(socket, state.connection)
def handle_info({:plug_conn, :sent}, {socket, state}),
do: {:noreply, {socket, state}, socket.read_timeout}
def handle_info(msg, {socket, state}) do
case Connection.handle_info(msg, socket, state.connection) do
{:continue, connection_state} ->
{:noreply, {socket, %{state | connection: connection_state}}, socket.read_timeout}
{:error, reason, connection_state} ->
{:stop, reason, {socket, %{state | connection: connection_state}}}
end
end
end

View File

@@ -0,0 +1,105 @@
defmodule Bandit.WebSocket.Handshake do
@moduledoc false
# Functions to support WebSocket handshaking as described in RFC6455§4.2 & RFC7692
import Plug.Conn
@type extensions :: [{String.t(), [{String.t(), String.t() | true}]}]
@spec handshake(Plug.Conn.t(), keyword(), keyword()) ::
{:ok, Plug.Conn.t(), Keyword.t()} | {:error, String.t()}
def handshake(%Plug.Conn{} = conn, opts, websocket_opts) do
with :ok <- Bandit.WebSocket.UpgradeValidation.validate_upgrade(conn) do
do_handshake(conn, opts, websocket_opts)
end
end
@spec do_handshake(Plug.Conn.t(), keyword(), keyword()) :: {:ok, Plug.Conn.t(), keyword()}
defp do_handshake(conn, opts, websocket_opts) do
requested_extensions = requested_extensions(conn)
{negotiated_params, returned_data} =
if Keyword.get(opts, :compress) && Keyword.get(websocket_opts, :compress, true) do
Bandit.WebSocket.PerMessageDeflate.negotiate(requested_extensions, websocket_opts)
else
{nil, []}
end
conn = send_handshake(conn, returned_data)
{:ok, conn, Keyword.put(opts, :compress, negotiated_params)}
end
@spec requested_extensions(Plug.Conn.t()) :: extensions()
defp requested_extensions(%Plug.Conn{} = conn) do
conn
|> get_req_header("sec-websocket-extensions")
|> Enum.flat_map(&Plug.Conn.Utils.list/1)
|> Enum.map(fn extension ->
[name | params] =
extension
|> String.split(";", trim: true)
|> Enum.map(&String.trim/1)
params = split_params(params)
{name, params}
end)
end
@spec split_params([String.t()]) :: [{String.t(), String.t() | true}]
defp split_params(params) do
params
|> Enum.map(fn param ->
param
|> String.split("=", trim: true)
|> Enum.map(&String.trim/1)
|> case do
[key, value] -> {key, value}
[key] -> {key, true}
end
end)
end
@spec send_handshake(Plug.Conn.t(), extensions()) :: Plug.Conn.t()
defp send_handshake(%Plug.Conn{} = conn, extensions) do
# Taken from RFC6455§4.2.2/5. Note that we can take for granted the existence of the
# sec-websocket-key header in the request, since we check for it in the handshake? call above
[client_key] = get_req_header(conn, "sec-websocket-key")
concatenated_key = client_key <> "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
hashed_key = :crypto.hash(:sha, concatenated_key)
server_key = Base.encode64(hashed_key)
headers =
[
{:upgrade, "websocket"},
{:connection, "Upgrade"},
{:"sec-websocket-accept", server_key}
] ++
websocket_extension_header(extensions) ++
conn.resp_headers
inform(conn, 101, headers)
end
@spec websocket_extension_header(extensions()) :: keyword()
defp websocket_extension_header([]), do: []
defp websocket_extension_header(extensions) do
extensions =
extensions
|> Enum.map_join(",", fn {extension, params} ->
params =
params
|> Enum.flat_map(fn
{_param, false} -> []
{param, true} -> [to_string(param)]
{param, value} -> [to_string(param) <> "=" <> to_string(value)]
end)
[to_string(extension) | params]
|> Enum.join(";")
end)
[{:"sec-websocket-extensions", extensions}]
end
end

View File

@@ -0,0 +1,151 @@
defmodule Bandit.WebSocket.PerMessageDeflate do
@moduledoc false
# Support for per-message deflate extension, per RFC7692§7
@typedoc "Encapsulates the state of a WebSocket permessage-deflate context"
@type t :: %__MODULE__{
server_no_context_takeover: boolean(),
client_no_context_takeover: boolean(),
server_max_window_bits: 8..15,
client_max_window_bits: 8..15,
inflate_context: :zlib.zstream(),
deflate_context: :zlib.zstream()
}
defstruct server_no_context_takeover: false,
client_no_context_takeover: false,
server_max_window_bits: 15,
client_max_window_bits: 15,
inflate_context: nil,
deflate_context: nil
@valid_params ~w[server_no_context_takeover client_no_context_takeover server_max_window_bits client_max_window_bits]
def negotiate(requested_extensions, opts) do
:proplists.get_all_values("permessage-deflate", requested_extensions)
|> Enum.find_value(&do_negotiate/1)
|> case do
nil -> {nil, []}
params -> {init(params, opts), "permessage-deflate": params}
end
end
defp do_negotiate(params) do
with params <- normalize_params(params),
true <- validate_params(params) do
resolve_params(params)
else
_ -> nil
end
end
defp normalize_params(params) do
params
|> Enum.map(fn
{"server_max_window_bits", true} -> {"server_max_window_bits", true}
{"server_max_window_bits", value} -> {"server_max_window_bits", parse(value)}
{"client_max_window_bits", true} -> {"client_max_window_bits", 15}
{"client_max_window_bits", value} -> {"client_max_window_bits", parse(value)}
value -> value
end)
end
defp parse(value) do
case Integer.parse(value) do
{int_value, ""} -> int_value
:error -> value
end
end
defp validate_params(params) do
no_invalid_params = params |> :proplists.split(@valid_params) |> elem(1) == []
no_repeat_params = params |> :proplists.get_keys() |> length() == length(params)
no_invalid_values =
:proplists.get_value("server_no_context_takeover", params) in [:undefined, true] &&
:proplists.get_value("client_no_context_takeover", params) in [:undefined, true] &&
:proplists.get_value("server_max_window_bits", params, 15) in 8..15 &&
:proplists.get_value("client_max_window_bits", params, 15) in 8..15
no_invalid_params && no_repeat_params && no_invalid_values
end
# This is where we finally determine which parameters to accept. Note that we don't convert to
# atoms until this stage to avoid potential atom exhaustion
defp resolve_params(params) do
@valid_params
|> Enum.flat_map(fn param_name ->
case :proplists.get_value(param_name, params) do
:undefined -> []
param -> [{String.to_existing_atom(param_name), param}]
end
end)
end
defp init(params, opts) do
instance = struct(__MODULE__, params)
inflate_context = :zlib.open()
:ok = :zlib.inflateInit(inflate_context, fix_bits(-instance.client_max_window_bits))
deflate_context = :zlib.open()
deflate_opts = Keyword.get(opts, :deflate_options, [])
:ok =
:zlib.deflateInit(
deflate_context,
Keyword.get(deflate_opts, :level, :default),
:deflated,
fix_bits(-instance.server_max_window_bits),
Keyword.get(deflate_opts, :mem_level, 8),
Keyword.get(deflate_opts, :strategy, :default)
)
%{instance | inflate_context: inflate_context, deflate_context: deflate_context}
end
# https://www.erlang.org/doc/man/zlib.html#deflateInit-6
defp fix_bits(-8), do: -9
defp fix_bits(other), do: other
# Note that we pass back the context to the caller even though it is unmodified locally
def inflate(data, %__MODULE__{} = context) do
inflated_data =
context.inflate_context
|> :zlib.inflate(<<data::binary, 0x00, 0x00, 0xFF, 0xFF>>)
|> IO.iodata_to_binary()
if context.client_no_context_takeover, do: :zlib.inflateReset(context.inflate_context)
{:ok, inflated_data, context}
rescue
e -> {:error, "Error encountered #{inspect(e)}"}
end
def inflate(_data, nil), do: {:error, :no_compress}
def deflate(data, %__MODULE__{} = context) do
deflated_data =
context.deflate_context
|> :zlib.deflate(data, :sync)
|> IO.iodata_to_binary()
deflated_size = byte_size(deflated_data) - 4
deflated_data =
case deflated_data do
<<deflated_data::binary-size(deflated_size), 0x00, 0x00, 0xFF, 0xFF>> -> deflated_data
deflated -> deflated
end
if context.server_no_context_takeover, do: :zlib.deflateReset(context.deflate_context)
{:ok, deflated_data, context}
rescue
e -> {:error, "Error encountered #{inspect(e)}"}
end
def deflate(_data, nil), do: {:error, :no_compress}
def close(%__MODULE__{} = context) do
:zlib.close(context.inflate_context)
:zlib.close(context.deflate_context)
end
end

View File

@@ -0,0 +1,75 @@
defprotocol Bandit.WebSocket.Socket do
@moduledoc false
#
# A protocol defining the low-level functionality of a WebSocket
#
@type t :: term()
@type frame_type :: :text | :binary | :ping | :pong
@type send_frame_stats :: [
send_binary_frame_bytes: non_neg_integer(),
send_binary_frame_count: non_neg_integer(),
send_ping_frame_bytes: non_neg_integer(),
send_ping_frame_count: non_neg_integer(),
send_pong_frame_bytes: non_neg_integer(),
send_pong_frame_count: non_neg_integer(),
send_text_frame_bytes: non_neg_integer(),
send_text_frame_count: non_neg_integer()
]
@spec send_frame(t(), {frame_type :: frame_type(), data :: iodata()}, boolean()) ::
send_frame_stats()
def send_frame(socket, data_and_frame_type, compressed)
@spec close(t(), code :: WebSock.close_detail()) :: :ok | {:error, :inet.posix()}
def close(socket, code)
end
defimpl Bandit.WebSocket.Socket, for: ThousandIsland.Socket do
@moduledoc false
#
# An implementation of Bandit.WebSocket.Socket for use with ThousandIsland.Socket instances
#
alias Bandit.WebSocket.Frame
@spec send_frame(@for.t(), {frame_type :: @protocol.frame_type(), data :: iodata()}, boolean()) ::
@protocol.send_frame_stats()
def send_frame(socket, {:text, data}, compressed) do
_ = do_send_frame(socket, %Frame.Text{fin: true, data: data, compressed: compressed})
[send_text_frame_count: 1, send_text_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:binary, data}, compressed) do
_ = do_send_frame(socket, %Frame.Binary{fin: true, data: data, compressed: compressed})
[send_binary_frame_count: 1, send_binary_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:ping, data}, false) do
_ = do_send_frame(socket, %Frame.Ping{data: data})
[send_ping_frame_count: 1, send_ping_frame_bytes: IO.iodata_length(data)]
end
def send_frame(socket, {:pong, data}, false) do
_ = do_send_frame(socket, %Frame.Pong{data: data})
[send_pong_frame_count: 1, send_pong_frame_bytes: IO.iodata_length(data)]
end
@spec close(@for.t(), non_neg_integer() | {non_neg_integer(), binary()}) ::
:ok | {:error, :inet.posix()}
def close(socket, {code, detail}) when is_integer(code) do
_ = do_send_frame(socket, %Frame.ConnectionClose{code: code, reason: detail})
@for.shutdown(socket, :write)
end
def close(socket, code) when is_integer(code) do
_ = do_send_frame(socket, %Frame.ConnectionClose{code: code})
@for.shutdown(socket, :write)
end
@spec do_send_frame(@for.t(), Frame.frame()) ::
:ok | {:error, :closed | :timeout | :inet.posix()}
defp do_send_frame(socket, frame) do
@for.send(socket, Frame.serialize(frame))
end
end

View File

@@ -0,0 +1,65 @@
defmodule Bandit.WebSocket.UpgradeValidation do
@moduledoc false
# Provides validation of WebSocket upgrade requests as described in RFC6455§4.2
# Validates that the request satisfies the requirements to issue a WebSocket upgrade response.
# Validations are performed based on the clauses laid out in RFC6455§4.2
#
# This function does not actually perform an upgrade or change the connection in any way
#
# Returns `:ok` if the connection satisfies the requirements for a WebSocket upgrade, and
# `{:error, reason}` if not
#
@spec validate_upgrade(Plug.Conn.t()) :: :ok | {:error, String.t()}
def validate_upgrade(conn) do
case Plug.Conn.get_http_protocol(conn) do
:"HTTP/1.1" -> validate_upgrade_http1(conn)
other -> {:error, "HTTP version #{other} unsupported"}
end
end
# Validate the conn per RFC6455§4.2.1
defp validate_upgrade_http1(conn) do
with :ok <- assert_method(conn, "GET"),
:ok <- assert_header_nonempty(conn, "host"),
:ok <- assert_header_contains(conn, "connection", "upgrade"),
:ok <- assert_header_contains(conn, "upgrade", "websocket"),
:ok <- assert_header_nonempty(conn, "sec-websocket-key"),
:ok <- assert_header_equals(conn, "sec-websocket-version", "13") do
:ok
end
end
defp assert_method(conn, verb) do
case conn.method do
^verb -> :ok
other -> {:error, "HTTP method #{other} unsupported"}
end
end
defp assert_header_nonempty(conn, header) do
case Plug.Conn.get_req_header(conn, header) do
[] -> {:error, "'#{header}' header is absent"}
_ -> :ok
end
end
defp assert_header_equals(conn, header, expected) do
case Plug.Conn.get_req_header(conn, header) |> Enum.map(&String.downcase(&1, :ascii)) do
[^expected] -> :ok
value -> {:error, "'#{header}' header must equal '#{expected}', got #{inspect(value)}"}
end
end
defp assert_header_contains(conn, header, needle) do
haystack = Plug.Conn.get_req_header(conn, header)
haystack
|> Enum.flat_map(&Plug.Conn.Utils.list/1)
|> Enum.any?(&(String.downcase(&1, :ascii) == needle))
|> case do
true -> :ok
false -> {:error, "'#{header}' header must contain '#{needle}', got #{inspect(haystack)}"}
end
end
end

View File

@@ -0,0 +1,95 @@
defmodule Bandit.MixProject do
use Mix.Project
def project do
[
app: :bandit,
version: "1.10.4",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_path(Mix.env()),
dialyzer: dialyzer(),
name: "Bandit",
description: "A pure-Elixir HTTP server built for Plug & WebSock apps",
source_url: "https://github.com/mtrudel/bandit",
package: [
maintainers: ["Mat Trudel"],
licenses: ["MIT"],
links: %{
"GitHub" => "https://github.com/mtrudel/bandit",
"Changelog" => "https://hexdocs.pm/bandit/changelog.html"
},
files: ["lib", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"]
],
docs: docs()
]
end
def application do
[extra_applications: [:logger], mod: {Bandit.Application, []}]
end
defp deps do
[
{:thousand_island, "~> 1.0"},
{:plug, "~> 1.18"},
{:websock, "~> 0.5"},
{:hpax, "~> 1.0"},
{:telemetry, "~> 0.4 or ~> 1.0"},
{:req, "~> 0.3", only: [:dev, :test]},
{:machete, ">= 0.0.0", only: [:dev, :test]},
{:ex_doc, "~> 0.24", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false},
{:credo, "~> 1.0", only: [:dev, :test], runtime: false},
{:mix_test_watch, "~> 1.0", only: :dev, runtime: false}
]
end
defp elixirc_path(:test), do: ["lib/", "test/support"]
defp elixirc_path(_), do: ["lib/"]
defp dialyzer do
[
plt_core_path: "priv/plts",
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_add_deps: :apps_direct,
plt_add_apps: [:ssl, :public_key],
flags: [
"-Werror_handling",
"-Wextra_return",
"-Wmissing_return",
"-Wunknown",
"-Wunmatched_returns",
"-Wunderspecs"
]
]
end
defp docs do
[
extras: [
"CHANGELOG.md": [title: "Changelog"],
"README.md": [title: "README"],
"lib/bandit/http1/README.md": [
filename: "HTTP1_README.md",
title: "HTTP/1 Implementation Notes"
],
"lib/bandit/http2/README.md": [
filename: "HTTP2_README.md",
title: "HTTP/2 Implementation Notes"
],
"lib/bandit/websocket/README.md": [
filename: "WebSocket_README.md",
title: "WebSocket Implementation Notes"
]
],
groups_for_extras: [
"Implementation Notes": Path.wildcard("lib/bandit/*/README.md")
],
skip_undefined_reference_warnings_on: Path.wildcard("**/*.md"),
main: "Bandit",
logo: "assets/ex_doc_logo.png"
]
end
end

Binary file not shown.

View File

@@ -0,0 +1,76 @@
# Changelog
## v0.1.11 (2025-08-02)
- allow `{:elixir_make, "~> 0.7 or ~> 0.8 or ~> 0.9"}`
## v0.1.10 (2024-03-10)
- allow `{:elixir_make, "~> 0.7 or ~> 0.8"}`
## v0.1.9 (2023-11-16)
- added an `exclude_current_target` option
- updated ex_doc
## v0.1.8 (2023-07-19)
### Changed
Fixed `CCPrecompiler.all_supported_targets(:fetch)`. It should fetch and merge default available compilers when `include_default_ones` is `true`.
## v0.1.7 (2022-03-13)
### Added
- using `:include_default_ones` in `project.cc_precompiler.compilers`. Default (cross-)compiler will be included if it's `true`, otherwise only specified targets will be used.
Default value of `:include_default_ones` is `false` to avoid breaking changes.
If a custom target has the same name as a default one, then the custom one will override the default configuration for that target (e.g., the `x86_64-linux-gnu` entry below will override the default gcc configuration and use clang instead).
```elixir
def project do
[
# ...
cc_precompiler: [
compilers: %{
{:unix, :linux} => %{
:include_default_ones => true,
"my-custom-target" => {
"my-custom-target-gcc",
"my-custom-target-g++"
},
"x86_64-linux-gnu" => {
"x86_64-linux-gnu-clang",
"x86_64-linux-gnu-clang++"
}
}
}
]
]
end
```
## v0.1.6 (2022-02-20)
### Added
- allow missing CC or CXX when detecting available targets by setting `allow_missing_compiler` to `true`.
Adding this option because there is no need to require the presence of both CC and CXX for projects that only uses one of them.
```elixir
def project do
[
# ...
cc_precompiler: [
# optional config key
# true - the corresponding target will be available as long as we can detect either `CC` or `CXX`
# false - both `CC` and `CXX` should be present on the system
# defaults to `false`
allow_missing_compiler: false,
# ...
],
# ...
]
end
```

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,342 @@
# Precompilation guide
This guide has two sections, the first one is intended for precompiler module developers. It covers a minimal example of creating a precompiler module. The second section is intended for library developers who want their library to be able to use precompiled artefacts in a simple way.
## Library Developer
This guide assumes you have already added `elixir_make` to your library and you have written a `Makefile` that compiles the native code in your project. Once your native code compile and works as expected, you are now ready to precompile it.
A full demo project is available on [cocoa-xu/cc_precompiler_example](https://github.com/cocoa-xu/cc_precompiler_example).
### Setup mix.exs
To use a precompiler module such as the `CCPrecompiler` example above, we first add the precompiler (`:cc_precompiler` here) and `:elixir_make` to `deps`.
```elixir
def deps do
[
# ...
{:elixir_make, "~> 0.6", runtime: false},
{:cc_precompiler, "~> 0.1", runtime: false, github: "cocoa-xu/cc_precompiler"}
# ...
]
end
```
Then add `:elixir_make` to the `compilers` list, and set `CCPrecompile` as the value for `make_precompiler`.
```elixir
@version "0.1.0"
def project do
[
# ...
compilers: [:elixir_make] ++ Mix.compilers(),
# elixir_make specific config
make_precompiler: {:nif, CCPrecompiler},
make_precompiler_url: "https://github.com/cocoa-xu/cc_precompiler_example/releases/download/v#{@version}/@{artefact_filename}",
make_precompiler_filename: "nif",
make_precompiler_priv_paths: ["nif.*"],
make_precompiler_unavailable_target: :compile,
# ...
]
end
```
Another required field is `make_precompiled_url`. It is a URL template to the artefact file.
`@{artefact_filename}` in the URL template string will be replaced by corresponding artefact filenames when fetching them. For example, `cc_precompiler_example-nif-2.16-x86_64-linux-gnu-0.1.0.tar.gz`.
Note that there is an optional config key for elixir_make, `make_precompiler_filename`. If the name (file extension does not count) of the shared library is different from your app's name, then `make_precompiler_filename` should be set. For example, if the app name is `"cc_precompiler_example"` while the name shared library is `"nif.so"` (or `"nif.dll"` on windows), then `make_precompiler_filename` should be set as `"nif"`.
Another optional config key is `make_precompiler_priv_paths`. For example, say the `priv` directory is organised as follows in Linux, macOS and Windows respectively,
Also, you can specify how to recover from unavailable targets using the `make_precompiler_unavailable_target` config key. Allowed values are `:compile` and `:ignore`. Defaults to `:compile`.
It is also possible to pass in a 2-arity function to `make_precompiler_unavailable_target`: the first argument is the triplet of the unavailable target, and the second argument is a list that contains all available targets given by the precompiler.
```
# Linux
.
├── assets
│ ├── model.onnx
│ └── data.json
├── lib
│ ├── libpriv1.so
│ ├── libpriv2.so
│ └── libpriv3.so
└── nif.so
# macOS
.
├── assets
│ ├── model.onnx
│ └── data.json
├── lib
│ ├── libpriv1.dylib
│ ├── libpriv2.dylib
│ └── libpriv3.dylib
└── nif.so
# Windows
.
├── assets
│ ├── model.onnx
│ └── data.json
├── lib
│ ├── libpriv1.dll
│ ├── libpriv2.dll
│ └── libpriv3.dll
└── nif.dll
```
By default, everything in `priv` will be included in the precompiled tar file. However, files in `assets` can be very large or platform-independent, therefore, we would like to only include the `nif.so` (`nif.dll`) file and everything in the `lib` directory in the precompiled tar file to reduce the footprint. In this case, we can set `make_precompiler_priv_paths` to `["nif.so", "nif.dll", "lib"]`.
Of course, wildcards (`?`, `**`, `*`) are supported when specifying files. For example, `["nif.*", "lib/*.so", "lib/*.dll", "lib/*.dylib"]` will include `nif.so` (Linux/macOS) or `nif.dll` (Windows), and `.so` or `.dll` files in the `lib` directory.
Directory structures and symbolic links are preserved.
### (Optional) Test the NIF code locally
To test the NIF code locally, you can either set `force_build` to `true` or append `"-dev"` to your NIF library's version string.
```elixir
@version "0.1.0-dev"
def project do
[
# either append `"-dev"` to your NIF library's version string
version: @version,
# or set force_build to true
force_build: true,
# ...
]
end
```
Doing so will ask `elixir_make` to only compile for the current host instead of building for all available targets.
```shell
$ mix compile
cc -shared -std=c11 -O3 -fPIC -I"/usr/local/lib/erlang/erts-13.0.3/include" -undefined dynamic_lookup -flat_namespace -undefined suppress "/Users/cocoa/git/cc_precompiler_example/c_src/cc_precompiler_example.c" -o "/Users/cocoa/Git/cc_precompiler_example/_build/dev/lib/cc_precompiler_example/priv/nif.so"
$ mix test
make: Nothing to be done for `build'.
Generated cc_precompiler_example app
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
Randomized with seed 102464
```
### Precompile for available targets
It's possible to either setup a CI task to do the precompilation job or precompile on a local machine and upload the precompiled artefacts.
To precompile for all targets on a local machine:
```shell
MIX_ENV=prod mix elixir_make.precompile
```
Environment variable `ELIXIR_MAKE_CACHE_DIR` can be used to set the cache dir for the precompiled artefacts, for instance, to output precompiled artefacts in the cache directory of the current working directory, `export ELIXIR_MAKE_CACHE_DIR="$(pwd)/cache"`.
To setup a CI task such as GitHub Actions, the following workflow file can be used for reference:
```yml
name: precompile
on:
push:
tags:
- 'v*'
jobs:
linux:
runs-on: ubuntu-latest
env:
MIX_ENV: "prod"
steps:
- uses: actions/checkout@v3
- uses: erlef/setup-beam@v1
with:
otp-version: "25.1"
elixir-version: "1.14"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake autoconf pkg-config bc m4 unzip zip \
gcc g++ \
gcc-i686-linux-gnu g++-i686-linux-gnu \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf \
gcc-riscv64-linux-gnu g++-riscv64-linux-gnu \
gcc-powerpc64le-linux-gnu g++-powerpc64le-linux-gnu \
gcc-s390x-linux-gnu g++-s390x-linux-gnu
- name: Get musl cross-compilers (Optional, use this if you have musl targets to compile)
run: |
for musl_arch in x86_64 aarch64 riscv64
do
wget "https://musl.cc/${musl_arch}-linux-musl-cross.tgz" -O "${musl_arch}-linux-musl-cross.tgz"
tar -xf "${musl_arch}-linux-musl-cross.tgz"
done
- name: Mix Test
run: |
# Optional, use this if you have musl targets to compile
for musl_arch in x86_64 aarch64 riscv64
do
export PATH="$(pwd)/${musl_arch}-linux-musl-cross/bin:${PATH}"
done
mix deps.get
MIX_ENV=test mix test
- name: Create precompiled library
run: |
export ELIXIR_MAKE_CACHE_DIR=$(pwd)/cache
mkdir -p "${ELIXIR_MAKE_CACHE_DIR}"
mix elixir_make.precompile
- uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
cache/*.tar.gz
macos:
runs-on: macos-11
env:
MIX_ENV: "prod"
steps:
- uses: actions/checkout@v3
- name: Install erlang and elixir
run: |
brew install erlang elixir
mix local.hex --force
mix local.rebar --force
- name: Mix Test
run: |
mix deps.get
MIX_ENV=test mix test
- name: Create precompiled library
run: |
export ELIXIR_MAKE_CACHE_DIR=$(pwd)/cache
mkdir -p "${ELIXIR_MAKE_CACHE_DIR}"
mix elixir_make.precompile
- uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
cache/*.tar.gz
```
### Generate checksum file
After CI has finished, you can fetch the precompiled binaries from GitHub.
```shell
$ MIX_ENV=prod mix elixir_make.checksum --all --ignore-unavailable
```
Meanwhile, a checksum file will be generated. In this example, the checksum file will be named as `checksum.exs` in current working directory.
This checksum file is extremely important in the scenario where you need to release a Hex package using precompiled NIFs. It's **MANDATORY** to include this file in your Hex package (by updating the `files` field in the `mix.exs`). Otherwise your package **won't work**.
```elixir
defp package do
[
files: [
# ...
"checksum.exs",
# ...
],
# ...
]
end
```
However, there is no need to track the checksum file in your version control system (git or other).
### (Optional) Test fetched artefacts can work locally
```shell
# delete previously built binaries so that
# elixir_make will try to restore the NIF library
# from the downloaded tarball file
$ rm -rf _build/prod/lib/cc_precompiler_example
# set to prod env and test everything
$ MIX_ENV=prod mix test
==> castore
Compiling 1 file (.ex)
Generated castore app
==> elixir_make
Compiling 5 files (.ex)
Generated elixir_make app
==> cc_precompiler
Compiling 1 file (.ex)
Generated cc_precompiler app
20:47:42.262 [debug] Restore NIF for current node from: /Users/cocoa/Library/Caches/cc_precompiler_example-nif-2.16-aarch64-apple-darwin-0.1.0.tar.gz
==> cc_precompiler_example
Compiling 1 file (.ex)
Generated cc_precompiler_example app
.
Finished in 0.01 seconds (0.00s async, 0.01s sync)
1 test, 0 failures
Randomized with seed 539590
```
## Recommended flow
To recap, the suggested flow is the following:
1. Choose an appropriate precompiler for your NIF library and set all necessary options in the `mix.exs`.
2. (Optional) Test if your NIF library compiles locally.
```shell
mix compile
mix test
```
3. (Optional) Test if your NIF library can precompile to all specified targets locally.
```shell
MIX_ENV=prod mix elixir_make.precompile
```
4. Precompile your library on CI or locally.
```shell
# locally
MIX_ENV=prod mix elixir_make.precompile
# CI
# please see the docs above
```
5. Fetch precompiled binaries from GitHub.
```shell
# only fetch artefact for current host
MIX_ENV=prod mix elixir_make.checksum --only-local --print
# fetch all
MIX_ENV=prod mix elixir_make.checksum --all --print
# to fetch all available artefacts at the moment
MIX_ENV=prod mix elixir_make.checksum --all --print --ignore-unavailable
```
6. (Optional) Test if the downloaded artefacts works as expected.
```shell
rm -rf _build/prod/lib/NIF_LIBRARY_NAME
MIX_ENV=prod mix test
```
6. Update Hex package to include the checksum file.
7. Release the package to Hex.pm (make sure your release includes the correct files).

View File

@@ -0,0 +1,342 @@
# CC Precompiler
[![Hex.pm](https://img.shields.io/hexpm/v/cc_precompiler.svg?style=flat&color=blue)](https://hex.pm/packages/cc_precompiler)
C/C++ Cross-compiler Precompiler is a library that supports [elixir_make](https://github.com/elixir-lang/elixir_make)'s precompilation feature. It's customisble and easy to extend.
The guide for how to `cc_precompiler` can be found in the `PRECOMPILATION_GUIED.md` file.
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `cc_precompiler` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:cc_precompiler, "~> 0.1.6"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/cc_precompiler>.
## Default Targets
By default, it will probe some well-known C/C++ crosss-compilers existing on your system:
#### Linux
| Target Triplet | Compiler Prefix, `prefix` | `CC` | `CXX` |
|:------------------------|:--------------------------|:----------------|:----------------|
| `x86_64-linux-gnu` | `x86_64-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
| `i686-linux-gnu` | `i686-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
| `aarch64-linux-gnu` | `aarch64-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
| `armv7l-linux-gnuabihf` | `arm-linux-gnueabihf-` | `#{prefix}gcc` | `#{prefix}g++` |
| `riscv64-linux-gnu` | `riscv64-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
| `powerpc64le-linux-gnu` | `powerpc64le-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
| `s390x-linux-gnu` | `s390x-linux-gnu-` | `#{prefix}gcc` | `#{prefix}g++` |
`cc_precompiler` will try to find `#{prefix}gcc` in `$PATH`, and if `#{prefix}gcc` can be found, then the correspondong target will be activiated. Otherwise, that target will be ignored.
#### macOS
| Target Triplet | Compiler Prefix, `prefix` | `CC` | `CXX` |
|:------------------------|:--------------------------|:-------------------|:-------------------|
| `x86_64-apple-darwin` | N/A | `gcc -arch x86_64` | `g++ -arch x86_64` |
| `aarch64-apple-darwin` | N/A | `gcc -arch arm64` | `g++ -arch arm64` |
`cc_precompiler` will try to find `gcc` in `$PATH`, and if `gcc` can be found, then both `x86_64` and `arm64` target will be activiated. Otherwise, both targets will be ignored.
#### Note
Triplet for current host will be always available, `:erlang.system_info(:system_architecture)`.
For macOS targets, the version part will be trimmed, e.g., `x86_64-apple-darwin21.6.0` will be `x86_64-apple-darwin`.
### Note
#### Conditionally switch on/off compilation flags depending on the target
During the compilation, `cc_precompiler` will set and update the environment variable `CC_PRECOMPILER_CURRENT_TARGET` to the current target's triplet.
The reason we might need this is that some 3rd party library may support some feature, like AVX, but they do not offer an auto-detection mechanism, and we have to manually switch on/off corresponding compilation flags.
An example with further explanation can be found on [cocoa-xu/nif_opt_flags](https://github.com/cocoa-xu/nif_opt_flags).
Last but not least, as the name suggests, this environment variable is set by `cc_precompiler`, thus if you switch to another precompiler, please check their manual for the equvilent.
### Customise Precompilation Targets
#### Quick Start
To add custom targets in addition to the default configuration, you can set `:include_default_ones` in `project.cc_precompiler.compilers`.
Default (cross-)compiler will be included if it's `true`, otherwise only specified targets will be used.
Default value of `:include_default_ones` is `false` to avoid breaking changes.
If a custom target has the same name as a default one, then the custom one will override the default configuration for that target (e.g., the `x86_64-linux-gnu` entry below will override the default gcc configuration and use clang instead).
```elixir
def project do
[
# ...
cc_precompiler: [
compilers: %{
{:unix, :linux} => %{
:include_default_ones => true,
"my-custom-target" => {
"my-custom-target-gcc",
"my-custom-target-g++"
},
"x86_64-linux-gnu" => {
"x86_64-linux-gnu-clang",
"x86_64-linux-gnu-clang++"
}
}
}
]
]
end
```
#### Fully Customise Precompilation Targets
```elixir
def project do
[
# ...
cc_precompiler: [
# optional config key
# false - target triplet for the current machine will be included in all available targets
# true - only targets listed in `compilers` will be included in all available targets
# defaults to `false`
only_listed_targets: true,
# optional config key
# this option is valid if and only if `only_listed_targets` is set to `true`
# - when `exclude_current_target` is `true`, it excludes current target (i.e., the machine that builds these binaries)
# from the list. This can be helpful when you're doing some complex cross-compilations,
# e.g., you'd like to specify which CI job should build for the x86_64-linux-gnu target
# this will force current target to be excluded from the list
exclude_current_target: false,
# optional config key
# clean up the priv directory between different targets
#
# for example, common assets for different targets can stay
# in the `priv` directory (instead of copying/downloading them
# multiple times)
# but target specific assets or .o files should be cleaned
# so that `make` can compile/generate these files for the next target
#
# the value for `cleanup` should be a string indicating the cleanup target
# in the makefile.
#
# for example, cc_precompiler will call `make mycleanup` between each build
# if the value for the key `cleanup` is set to `mycleanup`
#
# also, cc_precompiler will stop if `make mycleanup` exited with non-zero code
#
# the default value for this key is `nil`, and in such case, cc_precompiler
# will not do anything between each build
cleanup: "mycleanup",
# optional config key
# true - the corresponding target will be available as long as we can detect either `CC` or `CXX`
# false - both `CC` and `CXX` should be present on the system
# defaults to `false`
allow_missing_compiler: false,
# optional config that provides a map of available compilers
# on different systems
compilers: %{
# key (`:os.type()`)
# this allows us to provide different available targets
# on different systems
# value is a map that describes which compilers are available
#
# key == {:unix, :linux} => when compiling on Linux
{:unix, :linux} => %{
# key (target triplet) => `riscv64-linux-gnu`
# value => `PREFIX`
# - for strings, the string will be used as the prefix of
# the C and C++ compiler respectively, i.e.,
# CC=`#{prefix}gcc`
# CXX=`#{prefix}g++`
"riscv64-linux-gnu" => "riscv64-linux-gnu-",
# key (target triplet) => `armv7l-linux-gnueabihf`
# value => `{CC, CXX}`
# - for 2-tuples, the elements are the executable name of
# the C and C++ compiler respectively
"armv7l-linux-gnueabihf" => {
"arm-linux-gnueabihf-gcc",
"arm-linux-gnueabihf-g++"
},
# key (target triplet) => `armv7l-linux-gnueabihf`
# value => `{CC_EXECUTABLE, CXX_EXECUTABLE, CC_TEMPLATE, CXX_TEMPLATE}`
#
# - for 4-tuples, the first two elements are the same as in
# 2-tuple, the third and fourth elements are the template
# string for CC and CPP/CXX. for example,
#
# the last entry below shows the example of using zig as the
# crosscompiler for `aarch64-linux-musl`,
# the "CC" will be
# "zig cc -target aarch64-linux-musl",
# and "CXX" and "CPP" will be
# "zig c++ -target aarch64-linux-musl"
"aarch64-linux-musl" => {
"zig",
"zig",
"<% cc %> cc -target aarch64-linux-musl",
"<% cxx %> c++ -target aarch64-linux-musl"
}
},
# key == {:unix, :darwin} => when compiling on macOS
{:unix, :darwin} => %{
# key (target triplet) => `aarch64-apple-darwin`
# value => `{CC, CXX}`
"aarch64-apple-darwin" => {
"gcc -arch arm64", "g++ -arch arm64"
},
# key (target triplet) => `aarch64-linux-musl`
# value => `{CC_EXECUTABLE, CXX_EXECUTABLE, CC_TEMPLATE, CXX_TEMPLATE}`
"aarch64-linux-musl" => {
"zig",
"zig",
"<% cc %> cc -target aarch64-linux-musl",
"<% cxx %> c++ -target aarch64-linux-musl"
},
# key (target triplet) => `my-custom-target`
# - for 3-tuples, the first element should be `:script`
# the second element is the path to the elixir script file
# the third element is a 2-tuple,
# the first one is the name of the module
# the second one is custom args
# the module need to impl the `compile/5` callback declared in
# `CCPrecompiler.CompilationScript`
"my-custom-target" => {
:script, "custom.exs", {CustomCompile, []}
},
# key (target triplet) => `macos-universal`
# on macOS, CCPrecompiler also provides a builtin module to create
# universal binary for NIF libraries that only has a `nif.so` file
"macos-universal" => {
:script, "", {CCPrecompiler.UniversalBinary, []}
}
}
}
]
]
```
`CCPrecompiler.CompilationScript` is defined as follows,
```elixir
defmodule CCPrecompiler.CompilationScript do
@callback compile(
app :: atom(),
version :: String.t(),
nif_version :: String.t(),
target :: String.t(),
command_line_args :: [String.t()],
custom_args :: [String.t()]
) :: :ok | {:error, String.t()}
end
```
### Custom Compilation Script Examples
#### Compile with `ccache`
```elixir
defmodule CCPrecompiler.CCache do
@moduledoc """
Compile with ccache
## Example
"x86_64-linux-gnu" => {
:script, "custom.exs", {CCPrecompiler.CCache, []}
}
It's also possible to do this using a 4-tuple:
"x86_64-linux-musl" => {
"gcc", "g++", "ccache <% cc %>", "ccache <% cxx %>"
}
"""
@behaviour CCPrecompiler.CompilationScript
@impl CCPrecompiler.CompilationScript
def compile(app, version, nif_version, target, args, _custom_args) do
System.put_env("CC", "ccache gcc")
System.put_env("CXX", "ccache g++")
System.put_env("CPP", "ccache g++")
ElixirMake.Precompiler.mix_compile(args)
end
end
```
#### Build A Universal NIF Binary on macOS
File can be found at `lib/complation_script/universal_binary.ex`.
```elixir
defmodule CCPrecompiler.UniversalBinary do
@moduledoc """
Build a universal binary on macOS
## Example
"macos-universal" => {
:script, "universal_binary.exs", {CCPrecompiler.UniversalBinary, []}
}
"""
@behaviour CCPrecompiler.CompilationScript
@impl CCPrecompiler.CompilationScript
def compile(_app, _version, _nif_version, _target, args, _custom_args) do
config = Mix.Project.config()
app_priv = Path.join(Mix.Project.app_path(config), "priv")
make_precompiler_filename = config[:make_precompiler_filename] || "nif"
nif_file = "#{make_precompiler_filename}.so"
compiled_bin = Path.join(app_priv, nif_file)
x86_64_bin = Path.join(app_priv, "#{make_precompiler_filename}_x86_64.so")
aarch64_bin = Path.join(app_priv, "#{make_precompiler_filename}_aarch64.so")
File.rm(compiled_bin)
# first we compile `x86_64-apple-darwin`
:ok = System.put_env("CC", "gcc -arch x86_64")
System.put_env("CXX", "gcc -arch x86_64")
System.put_env("CPP", "g++ -arch x86_64")
ElixirMake.Compiler.compile(args)
File.rename!(compiled_bin, x86_64_bin)
# then we compile `aarch64-apple-darwin`
System.put_env("CC", "gcc -arch arm64")
System.put_env("CXX", "gcc -arch arm64")
System.put_env("CPP", "g++ -arch arm64")
ElixirMake.Compiler.compile(args)
File.rename!(compiled_bin, aarch64_bin)
{%IO.Stream{}, exit_status} = System.cmd("lipo", ["-create", "-output", compiled_bin, x86_64_bin, aarch64_bin])
File.rm!(x86_64_bin)
File.rm!(aarch64_bin)
if exit_status == 0 do
:ok
else
Mix.raise("Failed to create universal binary")
end
end
end
```

View File

@@ -0,0 +1,28 @@
{<<"links">>,
[{<<"Changelog">>,
<<"https://github.com/cocoa-xu/cc_precompiler/blob/v0.1.11/CHANGELOG.md">>},
{<<"GitHub">>,<<"https://github.com/cocoa-xu/cc_precompiler">>},
{<<"Precompilation Guide">>,
<<"https://github.com/cocoa-xu/cc_precompiler/blob/v0.1.11/PRECOMPILATION_GUIDE.md">>},
{<<"Readme">>,
<<"https://github.com/cocoa-xu/cc_precompiler/blob/v0.1.11/README.md">>}]}.
{<<"name">>,<<"cc_precompiler">>}.
{<<"version">>,<<"0.1.11">>}.
{<<"description">>,
<<"NIF library Precompiler that uses C/C++ (cross-)compiler.">>}.
{<<"elixir">>,<<"~> 1.11">>}.
{<<"app">>,<<"cc_precompiler">>}.
{<<"files">>,
[<<"lib">>,<<"lib/cc_precompiler.ex">>,<<"lib/compilation_script">>,
<<"lib/compilation_script/universal_binary.ex">>,
<<"lib/compilation_script/compilation_script.ex">>,<<"mix.exs">>,
<<"README.md">>,<<"LICENSE">>,<<"CHANGELOG.md">>,
<<"PRECOMPILATION_GUIDE.md">>]}.
{<<"licenses">>,[<<"Apache-2.0">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"elixir_make">>},
{<<"app">>,<<"elixir_make">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.7 or ~> 0.8 or ~> 0.9">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,576 @@
defmodule CCPrecompiler do
@moduledoc """
Precompile with existing crosscompiler in the system.
"""
require Logger
@behaviour ElixirMake.Precompiler
# The default configuration for this precompiler module on linux systems.
# It will detect for the following targets
# - x86_64-linux-gnu
# - i686-linux-gnu
# - aarch64-linux-gnu
# - armv7l-linux-gnueabihf
# - riscv64-linux-gnu
# - powerpc64le-linux-gnu
# - s390x-linux-gnu
# by trying to find the corresponding executable, i.e.,
# - x86_64-linux-gnu-gcc
# - i686-linux-gnu-gcc
# - aarch64-linux-gnu-gcc
# - arm-linux-gnueabihf-gcc
# - riscv64-linux-gnu-gcc
# - powerpc64le-linux-gnu-gcc
# - s390x-linux-gnu-gcc
# (this module will only try to find the CC executable, a step further
# will be trying to compile a simple C/C++ program using them)
@default_compilers %{
{:unix, :linux} => %{
"x86_64-linux-gnu" => "x86_64-linux-gnu-",
"i686-linux-gnu" => "i686-linux-gnu-",
"aarch64-linux-gnu" => "aarch64-linux-gnu-",
"armv7l-linux-gnueabihf" => "arm-linux-gnueabihf-",
"riscv64-linux-gnu" => "riscv64-linux-gnu-",
"powerpc64le-linux-gnu" => "powerpc64le-linux-gnu-",
"s390x-linux-gnu" => "s390x-linux-gnu-"
},
{:unix, :darwin} => %{
"x86_64-apple-darwin" => {
"gcc",
"g++",
"<%= cc %> -arch x86_64",
"<%= cxx %> -arch x86_64"
},
"aarch64-apple-darwin" => {
"gcc",
"g++",
"<%= cc %> -arch arm64",
"<%= cxx %> -arch arm64"
}
},
{:win32, :nt} => %{
"x86_64-windows-msvc" => {"cl", "cl"}
}
}
defp default_compilers, do: @default_compilers
defp user_config, do: Mix.Project.config()[:cc_precompiler] || default_compilers()
defp compilers, do: Access.get(user_config(), :compilers, default_compilers())
defp compilers_current_os,
do:
{Access.get(compilers(), :os.type(), %{}), Access.get(default_compilers(), :os.type(), %{})}
defp compilers_current_os_with_override do
{compiler_map1, compiler_map2} = compilers_current_os()
if Map.has_key?(compiler_map1, :include_default_ones) do
include_default_ones = Map.get(compiler_map1, :include_default_ones, false)
compiler_map1 = Map.delete(compiler_map1, :include_default_ones)
if include_default_ones == true do
Map.merge(compiler_map1, compiler_map2, fn _, _, user_override -> user_override end)
else
compiler_map1
end
else
compiler_map1
end
end
defp only_listed_targets, do: Access.get(user_config(), :only_listed_targets, false)
defp exclude_current_target, do: Access.get(user_config(), :exclude_current_target, false)
defp allow_missing_compiler, do: Access.get(user_config(), :allow_missing_compiler, false)
@impl ElixirMake.Precompiler
def current_target do
current_target_from_env = current_target_from_env()
if current_target_from_env do
# overwrite current target triplet from environment variables
{:ok, current_target_from_env}
else
current_target(:os.type())
end
end
defp current_target_from_env do
arch = System.get_env("TARGET_ARCH")
os = System.get_env("TARGET_OS")
abi = System.get_env("TARGET_ABI")
if !Enum.all?([arch, os, abi], &Kernel.is_nil/1) do
"#{arch}-#{os}-#{abi}"
end
end
def current_target({:win32, _}) do
processor_architecture =
String.downcase(String.trim(System.get_env("PROCESSOR_ARCHITECTURE")))
# https://docs.microsoft.com/en-gb/windows/win32/winprog64/wow64-implementation-details?redirectedfrom=MSDN
partial_triplet =
case processor_architecture do
"amd64" ->
"x86_64-windows-"
"ia64" ->
"ia64-windows-"
"arm64" ->
"aarch64-windows-"
"x86" ->
"x86-windows-"
end
{compiler, _} = :erlang.system_info(:c_compiler_used)
case compiler do
:msc ->
{:ok, partial_triplet <> "msvc"}
:gnuc ->
{:ok, partial_triplet <> "gnu"}
other ->
{:ok, partial_triplet <> Atom.to_string(other)}
end
end
def current_target({:unix, _}) do
# get current target triplet from `:erlang.system_info/1`
system_architecture = to_string(:erlang.system_info(:system_architecture))
current = String.split(system_architecture, "-", trim: true)
case length(current) do
4 ->
{:ok, "#{Enum.at(current, 0)}-#{Enum.at(current, 2)}-#{Enum.at(current, 3)}"}
3 ->
case :os.type() do
{:unix, :darwin} ->
# could be something like aarch64-apple-darwin21.0.0
# but we don't really need the last 21.0.0 part
if String.match?(Enum.at(current, 2), ~r/^darwin.*/) do
{:ok, "#{Enum.at(current, 0)}-#{Enum.at(current, 1)}-darwin"}
else
{:ok, system_architecture}
end
_ ->
{:ok, system_architecture}
end
_ ->
{:error, "cannot decide current target"}
end
end
defp only_local do
System.get_env("CC_PRECOMPILER_PRECOMPILE_ONLY_LOCAL") == "true"
end
@impl ElixirMake.Precompiler
def all_supported_targets(:compile) do
# this callback is expected to return a list of string for
# all supported targets by this precompiler. in this
# implementation, we will try to find a few crosscompilers
# available in the system.
# Note that this implementation is mainly used for demonstration
# purpose, therefore the hardcoded compiler names are used in
# DEBIAN/Ubuntu Linux (as I only installed these ones at the
# time of writing this example)
available_targets = find_all_available_targets()
targets =
case {only_local(), only_listed_targets(), current_target()} do
{true, true, {:ok, current}} ->
if Enum.member?(available_targets, current) do
[current]
else
[]
end
{true, _, {:error, err_msg}} ->
Mix.raise(err_msg)
{true, false, {:ok, current}} ->
Enum.uniq([current] ++ available_targets)
{false, true, _} ->
available_targets
{false, false, {:ok, current}} ->
Enum.uniq([current] ++ available_targets)
end
if exclude_current_target() do
case current_target() do
{:ok, current} ->
targets -- [current]
_ ->
targets
end
else
targets
end
end
@impl ElixirMake.Precompiler
def all_supported_targets(:fetch) do
Enum.map(compilers(), fn {os, compilers} ->
Enum.map(Map.keys(compilers), fn key ->
if key == :include_default_ones do
Map.keys(default_compilers()[os])
else
key
end
end)
end)
|> List.flatten()
end
@impl ElixirMake.Precompiler
def unavailable_target(_) do
if only_listed_targets() do
:ignore
else
:compile
end
end
defp find_all_available_targets do
compilers = compilers_current_os_with_override()
compilers
|> Map.keys()
|> Enum.map(&find_available_compilers(&1, Map.get(compilers, &1)))
|> Enum.reject(fn x -> x == nil end)
end
defp find_available_compilers(triplet, prefix) when is_binary(prefix) do
if ensure_executable(["#{prefix}gcc", "#{prefix}g++"]) do
Logger.debug("Found compiler for #{triplet}")
triplet
else
Logger.debug("Compiler not found for #{triplet}")
nil
end
end
defp find_available_compilers(triplet, {cc, cxx}) when is_binary(cc) and is_binary(cxx) do
if ensure_executable([cc, cxx]) do
Logger.debug("Found compiler for #{triplet}")
triplet
else
Logger.debug("Compiler not found for #{triplet}")
nil
end
end
defp find_available_compilers(triplet, {:script, _, _}) do
triplet
end
defp find_available_compilers(triplet, {cc_executable, cxx_executable, _, _})
when is_binary(cc_executable) and is_binary(cxx_executable) do
if ensure_executable([cc_executable, cxx_executable]) do
Logger.debug("Found compiler for #{triplet}")
triplet
else
Logger.debug("Compiler not found for #{triplet}")
nil
end
end
defp find_available_compilers(triplet, invalid) do
Mix.raise(
"Invalid configuration for #{triplet}, expecting a string, 2-tuple or 4-tuple. Got `#{inspect(invalid)}`"
)
end
defp ensure_executable(executable_list) when is_list(executable_list) do
if allow_missing_compiler() do
Enum.any?(executable_list, &System.find_executable/1)
else
Enum.all?(executable_list, &System.find_executable/1)
end
end
@impl ElixirMake.Precompiler
def build_native(args) do
# In this callback we just build the NIF library natively,
# and because this precompiler module is designed for NIF
# libraries that use C/C++ as the main language with Makefile,
# we can just call `ElixirMake.Precompiler.mix_compile(args)`
#
# It's also possible to forward this call to:
#
# `precompile(args, elem(current_target(), 1))`
#
# This could be useful when the precompiler is using a universal
# (cross-)compiler, say zig. in this way, the compiled binaries
# (`mix compile`) will be consistent as the corresponding precompiled
# one (with `mix elixir_make.precompile`)
#
# However, if you'd prefer to having the same behaviour for `mix compile`
# then the following line is okay
ElixirMake.Precompiler.mix_compile(args)
end
@impl ElixirMake.Precompiler
def precompile(args, target) do
# in this callback we compile the NIF library for a given target
config = Mix.Project.config()
app = config[:app]
version = config[:version]
priv_paths = config[:make_precompiler_priv_paths] || ["."]
saved_cc = System.get_env("CC") || ""
saved_cxx = System.get_env("CXX") || ""
saved_cpp = System.get_env("CPP") || ""
Logger.debug("Current compiling target: #{target}")
cc_cxx = get_cc_and_cxx(target)
# remove files in the lists
app_priv = Path.join(Mix.Project.app_path(config), "priv")
case priv_paths do
["."] ->
File.rm_rf!(app_priv)
_ ->
for include <- priv_paths,
file <- Path.wildcard(Path.join(app_priv, include)) do
File.rm_rf(file)
end
end
File.mkdir_p!(app_priv)
case cc_cxx do
{cc, cxx} ->
System.put_env("CC", cc)
System.put_env("CXX", cxx)
System.put_env("CPP", cxx)
System.put_env("CC_PRECOMPILER_CURRENT_TARGET", target)
ElixirMake.Precompiler.mix_compile(args)
{:script, module, custom_args} ->
System.put_env("CC_PRECOMPILER_CURRENT_TARGET", target)
Kernel.apply(module, :compile, [
app,
version,
"#{:erlang.system_info(:nif_version)}",
target,
args,
custom_args
])
end
System.put_env("CC", saved_cc)
System.put_env("CXX", saved_cxx)
System.put_env("CPP", saved_cpp)
:ok
end
defp get_cc_and_cxx(triplet) do
case Access.get(compilers_current_os_with_override(), triplet, nil) do
nil ->
cc = System.get_env("CC")
cxx = System.get_env("CXX")
cpp = System.get_env("CPP")
case {cc, cxx, cpp} do
{nil, _, _} ->
{"gcc", "g++"}
{_, nil, nil} ->
{"gcc", "g++"}
{_, _, nil} ->
{cc, cxx}
{_, nil, _} ->
{cc, cpp}
{_, _, _} ->
{cc, cxx}
end
{cc, cxx} ->
{cc, cxx}
prefix when is_binary(prefix) ->
{"#{prefix}gcc", "#{prefix}g++"}
{:script, script_path, {module, args}} ->
case {script_path, module} do
{"", CCPrecompiler.UniversalBinary} ->
{:script, module, args}
_ ->
Code.require_file(script_path)
{:script, module, args}
end
{cc, cxx, cc_args, cxx_args} ->
{EEx.eval_string(cc_args, cc: cc), EEx.eval_string(cxx_args, cxx: cxx)}
end
end
@impl true
def post_precompile_target(target) do
config = Mix.Project.config()
cc_precompiler_config = config[:cc_precompiler]
cleanup(config, cc_precompiler_config[:cleanup], target)
end
defp cleanup(_, nil, _), do: :ok
defp cleanup(config, make_target, current_precompilation_target) when is_binary(make_target) do
exec =
System.get_env("MAKE") ||
os_specific_executable(Keyword.get(config, :make_executable, :default))
makefile = Keyword.get(config, :make_makefile, :default)
env = Keyword.get(config, :make_env, %{})
env = if is_function(env), do: env.(), else: env
env = default_env(config, env, current_precompilation_target)
# In OTP 19, Erlang's `open_port/2` ignores the current working
# directory when expanding relative paths. This means that `:make_cwd`
# must be an absolute path. This is a different behaviour from earlier
# OTP versions and appears to be a bug. It is being tracked at
# https://bugs.erlang.org/browse/ERL-175.
cwd = Keyword.get(config, :make_cwd, ".") |> Path.expand(File.cwd!())
if String.contains?(cwd, " ") do
IO.warn(
"the absolute path to the makefile for this project contains spaces. Make might " <>
"not work properly if spaces are present in the path. The absolute path is: " <>
inspect(cwd)
)
end
base = exec |> Path.basename() |> Path.rootname()
args = args_for_makefile(base, makefile) ++ [make_target]
case cmd(exec, args, cwd, env) do
0 ->
:ok
exit_status ->
raise_cleanup_error(exec, exit_status)
end
end
defp raise_cleanup_error(exec, exit_status) do
Mix.raise(~s{Could not complete cleanup work with "#{exec}" (exit status: #{exit_status}).\n})
end
# Returns a map of default environment variables
# Defaults may be overwritten.
defp default_env(config, default_env, current_precompilation_target) do
root_dir = :code.root_dir()
erl_interface_dir = Path.join(root_dir, "usr")
erts_dir = Path.join(root_dir, "erts-#{:erlang.system_info(:version)}")
erts_include_dir = Path.join(erts_dir, "include")
erl_ei_lib_dir = Path.join(erl_interface_dir, "lib")
erl_ei_include_dir = Path.join(erl_interface_dir, "include")
Map.merge(
%{
# Don't use Mix.target/0 here for backwards compatibility
"MIX_TARGET" => env("MIX_TARGET", "host"),
"MIX_ENV" => to_string(Mix.env()),
"MIX_BUILD_PATH" => Mix.Project.build_path(config),
"MIX_APP_PATH" => Mix.Project.app_path(config),
"MIX_COMPILE_PATH" => Mix.Project.compile_path(config),
"MIX_CONSOLIDATION_PATH" => Mix.Project.consolidation_path(config),
"MIX_DEPS_PATH" => Mix.Project.deps_path(config),
"MIX_MANIFEST_PATH" => Mix.Project.manifest_path(config),
# Rebar naming
"ERL_EI_LIBDIR" => env("ERL_EI_LIBDIR", erl_ei_lib_dir),
"ERL_EI_INCLUDE_DIR" => env("ERL_EI_INCLUDE_DIR", erl_ei_include_dir),
# erlang.mk naming
"ERTS_INCLUDE_DIR" => env("ERTS_INCLUDE_DIR", erts_include_dir),
"ERL_INTERFACE_LIB_DIR" => env("ERL_INTERFACE_LIB_DIR", erl_ei_lib_dir),
"ERL_INTERFACE_INCLUDE_DIR" => env("ERL_INTERFACE_INCLUDE_DIR", erl_ei_include_dir),
# Disable default erlang values
"BINDIR" => nil,
"ROOTDIR" => nil,
"PROGNAME" => nil,
"EMU" => nil,
# cc_precompiler
"CC_PRECOMPILER_CURRENT_TARGET" => current_precompilation_target
},
default_env
)
end
defp os_specific_executable(exec) when is_binary(exec) do
exec
end
defp os_specific_executable(:default) do
case :os.type() do
{:win32, _} ->
cond do
System.find_executable("nmake") -> "nmake"
System.find_executable("make") -> "make"
true -> "nmake"
end
{:unix, type} when type in [:freebsd, :openbsd, :netbsd] ->
"gmake"
_ ->
"make"
end
end
# Returns a list of command-line args to pass to make (or nmake/gmake) in
# order to specify the makefile to use.
defp args_for_makefile("nmake", :default), do: ["/F", "Makefile.win"]
defp args_for_makefile("nmake", makefile), do: ["/F", makefile]
defp args_for_makefile(_, :default), do: []
defp args_for_makefile(_, makefile), do: ["-f", makefile]
# Runs `exec [args]` in `cwd` and prints the stdout and stderr in real time,
# as soon as `exec` prints them (using `IO.Stream`).
defp cmd(exec, args, cwd, env) do
opts = [
into: IO.stream(:stdio, :line),
stderr_to_stdout: true,
cd: cwd,
env: env
]
{%IO.Stream{}, status} = System.cmd(find_executable(exec), args, opts)
status
end
defp find_executable(exec) do
System.find_executable(exec) ||
Mix.raise("""
"#{exec}" not found in the path. If you have set the MAKE environment variable,
please make sure it is correct.
""")
end
defp env(var, default) do
System.get_env(var) || default
end
end

View File

@@ -0,0 +1,10 @@
defmodule CCPrecompiler.CompilationScript do
@callback compile(
app :: atom(),
version :: String.t(),
nif_version :: String.t(),
target :: String.t(),
command_line_args :: [String.t()],
custom_args :: [String.t()]
) :: :ok | {:error, String.t()}
end

View File

@@ -0,0 +1,54 @@
defmodule CCPrecompiler.UniversalBinary do
@moduledoc """
Build a universal binary on macOS
## Example
"macos-universal" => {
:script, "", {CCPrecompiler.UniversalBinary, []}
}
"""
@behaviour CCPrecompiler.CompilationScript
@impl CCPrecompiler.CompilationScript
def compile(_app, _version, _nif_version, _target, args, _custom_args) do
config = Mix.Project.config()
app_priv = Path.join(Mix.Project.app_path(config), "priv")
make_precompiler_filename = config[:make_precompiler_filename] || "nif"
nif_file = "#{make_precompiler_filename}.so"
compiled_bin = Path.join(app_priv, nif_file)
x86_64_bin = Path.join(app_priv, "#{make_precompiler_filename}_x86_64.so")
aarch64_bin = Path.join(app_priv, "#{make_precompiler_filename}_aarch64.so")
File.rm(compiled_bin)
# first we compile `x86_64-apple-darwin`
:ok = System.put_env("CC", "gcc -arch x86_64")
System.put_env("CXX", "gcc -arch x86_64")
System.put_env("CPP", "g++ -arch x86_64")
ElixirMake.Compiler.compile(args)
File.rename!(compiled_bin, x86_64_bin)
# then we compile `aarch64-apple-darwin`
System.put_env("CC", "gcc -arch arm64")
System.put_env("CXX", "gcc -arch arm64")
System.put_env("CPP", "g++ -arch arm64")
ElixirMake.Compiler.compile(args)
File.rename!(compiled_bin, aarch64_bin)
{_, exit_status} =
System.cmd("lipo", ["-create", "-output", compiled_bin, x86_64_bin, aarch64_bin])
File.rm!(x86_64_bin)
File.rm!(aarch64_bin)
if exit_status == 0 do
:ok
else
Mix.raise("Failed to create universal binary")
end
end
end

View File

@@ -0,0 +1,63 @@
defmodule CCPrecompiler.MixProject do
use Mix.Project
@app :cc_precompiler
@version "0.1.11"
@github_url "https://github.com/cocoa-xu/cc_precompiler"
def project do
[
app: @app,
version: @version,
elixir: "~> 1.11",
start_permanent: Mix.env() == :prod,
deps: deps(),
docs: docs(),
description: "NIF library Precompiler that uses C/C++ (cross-)compiler.",
package: package()
]
end
def application do
[
extra_applications: [:logger, :eex]
]
end
defp deps do
[
{:elixir_make, "~> 0.7 or ~> 0.8 or ~> 0.9", runtime: false},
# docs
{:ex_doc, ">= 0.0.0", only: :docs, runtime: false}
]
end
defp package do
[
name: Atom.to_string(@app),
files: ~w(lib mix.exs README* LICENSE* *.md),
licenses: ["Apache-2.0"],
links: links()
]
end
defp docs do
[
main: "PRECOMPILATION_GUIDE",
source_ref: "v#{@version}",
source_url: @github_url,
extras: [
"PRECOMPILATION_GUIDE.md",
"CHANGELOG.md"
]
]
end
defp links do
%{
"GitHub" => @github_url,
"Readme" => "#{@github_url}/blob/v#{@version}/README.md",
"Precompilation Guide" => "#{@github_url}/blob/v#{@version}/PRECOMPILATION_GUIDE.md",
"Changelog" => "#{@github_url}/blob/v#{@version}/CHANGELOG.md"
}
end
end

View File

@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test,examples,integration_test}/**/*.{ex,exs}"]
]

Binary file not shown.

View File

@@ -0,0 +1,112 @@
# Changelog
## v2.9.0 (2026-01-10)
* Enhancements
* Whenever possible, have error messages that include a PID also include ancestors and one of process label, name, or initial call
* Allow clients to retry when specified
* Remove sensitive options before calling `after_connect`
## v2.8.1 (2025-06-24)
* Enhancements
* Automatically set and read process labels in error reports
## v2.8.0 (2025-06-24)
* Enhancements
* Allow `unallow_existing` as an opt to `ownership_allow/4`
* Improve ETS performance by enabling descentralized counters
* Increase default queue interval to 2000ms
## v2.7.0 (2024-07-02)
* Enhancements
* Add API for retrieving pool metrics
* Include a built-in listener that emits telemetry events
* Bug fixes
* Discard EXIT messages from trapped exits
## v2.6.0 (2023-10-15)
* Enhancements
* Call `disconnect` on terminate
* Allow `handle_begin` callbacks to return query for logging purposes
* Add `:connection_listeners_tag`
* Add `DBConnection.available_connection_options/0`
* Add `DBConnection.available_start_options/0`
## v2.5.0 (2023-04-10)
* Internal changes
* No longer depend on `connection`
## v2.4.3 (2022-11-22)
* Bug fixes
* Fix bug where `disconnect_all/2` interval would be disabled above 4294ms
* Add `:idle_limit` to limit the amount of disconnections on a ping
## v2.4.2 (2022-03-03)
* Enhancements
* Add `DBConnection.connection_module/1`
## v2.4.1 (2021-10-14)
* Enhancements
* Add `DBConnection.disconnect_all/2`
## v2.4.0 (2021-04-02)
* Enhancements
* Add telemetry events for connection errors
* Use `:rand` default algorithm
* Allow decentralized lookups on DBConnection.Ownership
## v2.3.1 (2020-11-25)
* Enhancements
* Add `:connection_listeners` to `DBConnection.start_link/2`
* Allow connection `~> 1.0`
## v2.3.0 (2020-10-14)
This release requires Elixir v1.7+.
* Bug fixes
* Fix deprecation warnings related to the use of `System.stacktrace()`
## v2.2.2 (2020-04-22)
* Bug fixes
* Make sure all idle connections in the pool are pinged on each idle interval
## v2.2.1 (2020-02-04)
* Enhancements
* Remove warnings
## v2.2.0 (2019-12-11)
* Enhancements
* Add `:idle_time` to `DBConnection.LogEntry`
* Ping all stale connections on idle interval
* Add `crash_reason` to relevant Logger error reports
* Ping all stale connections on idle interval. One possible downside of this approach is that we may shut down all connections at once and if there is a request around this time, the response time will be higher. However, this is likely better than the current approach, where we ping only the first one, which means we can have a pool of stale connections. The current behaviour is the same as in v1.0
## v2.1.1 (2019-07-17)
* Enhancements
* Reduce severity in client exits to info
* Improve error message on redirect checkout
* Bug fixes
* Make sure ownership timeout is respected on automatic checkouts
## v2.1.0 (2019-06-07)
* Enhancements
* Require Elixir v1.6+
* Include client stacktrace on check out timeouts

View File

@@ -0,0 +1,100 @@
# DBConnection
Database connection behaviour and database connection pool designed for
handling transaction, prepare/execute, cursors and client process
describe/encode/decode.
Examples of using the `DBConnection` behaviour are available in
`./examples/db_agent/` and `./examples/tcp_connection/`.
There is also [a series of articles on building database adapters](http://blog.plataformatec.com.br/2018/11/building-a-new-mysql-adapter-for-ecto-part-i-hello-world/). It includes articles covering both DBConnection and Ecto integrations.
## Contributing
Run unit tests with:
$ mix test
To run the integration tests (for each available pool):
$ mix test.pools
To run all tests:
$ mix test.all
## Design
This library is made of four main modules:
* `DBConnection` - this is the code running on the client
and the specification of the DBConnection API
* `DBConnection.Connection` - this is the process that
establishes the database connection
* `DBConnection.ConnectionPool` - this is the connection
pool. A client asks the connection pool for a connection.
There is also an ownership pool, used mostly during tests,
which we won't discuss here.
* `DBConnection.Holder` - the holder is responsible for
keeping the connection and checkout state. It is modelled
by using an ETS table.
Once a connection is created, it creates a holder and
assigns the connection pool as the heir. Then the holder
is promptly given away to the pool. The connection itself
is mostly a dummy. It is there to handle connections and pings.
The state itself (such as the socket) is all in the holder.
Once there is a checkout, the pool gives the holder to the
client process and stores all relevant information in the
holder table itself. If the client terminates without
checking in, then the holder is given back to the pool via
the heir mechanism. The pool will then discard the connection.
One important design detail in DBConnection is that it avoids
copying data. Other database libraries would send a request
to the connection process, perform the query in the connection
process, and then send it back to the client. This means a lot of
data copying in Elixir. DBConnection keeps the socket in the
holder and works on it directly.
DBConnection also takes all of the care necessary to handle
failures, and it shuts down the connection and the socket
whenever the client does not check in the connection to avoid
recycling sockets/connections in a corrupted state (such as a socket
that is stuck inside a transaction).
### Deadlines
When a checkout happens, a deadline is started by the client
to send a message to the pool after a time interval. If the
deadline is reached and the connection is still checked out,
the holder is deleted and the connection is terminated. If the
client tries to use a terminated connection, an error will
be raised (see `Holder.handle/4`).
### Pool
The queuing algorithm used by the pool is [CoDel](https://queue.acm.org/appendices/codel.html)
which allows us to plan for overloads and reject requests
without clogging the pool once checkouts do not read a certain
target.
## License
Copyright 2015 James Fish
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,33 @@
{<<"links">>,
[{<<"GitHub">>,<<"https://github.com/elixir-ecto/db_connection">>}]}.
{<<"name">>,<<"db_connection">>}.
{<<"version">>,<<"2.9.0">>}.
{<<"description">>,
<<"Database connection behaviour for database transactions and connection pooling">>}.
{<<"elixir">>,<<"~> 1.11">>}.
{<<"files">>,
[<<"lib">>,<<"lib/db_connection">>,<<"lib/db_connection/util.ex">>,
<<"lib/db_connection/backoff.ex">>,<<"lib/db_connection/log_entry.ex">>,
<<"lib/db_connection/ownership.ex">>,
<<"lib/db_connection/connection_pool">>,
<<"lib/db_connection/connection_pool/pool.ex">>,
<<"lib/db_connection/task.ex">>,<<"lib/db_connection/connection_error.ex">>,
<<"lib/db_connection/query.ex">>,<<"lib/db_connection/holder.ex">>,
<<"lib/db_connection/ownership">>,
<<"lib/db_connection/ownership/proxy.ex">>,
<<"lib/db_connection/ownership/manager.ex">>,
<<"lib/db_connection/watcher.ex">>,<<"lib/db_connection/pool.ex">>,
<<"lib/db_connection/connection_pool.ex">>,
<<"lib/db_connection/connection.ex">>,
<<"lib/db_connection/telemetry_listener.ex">>,
<<"lib/db_connection/app.ex">>,<<"lib/db_connection.ex">>,
<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"CHANGELOG.md">>]}.
{<<"app">>,<<"db_connection">>}.
{<<"licenses">>,[<<"Apache-2.0">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"telemetry">>},
{<<"app">>,<<"telemetry">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.4 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
defmodule DBConnection.App do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
{Task.Supervisor, name: DBConnection.Task},
dynamic_supervisor(DBConnection.Ownership.Supervisor),
dynamic_supervisor(DBConnection.ConnectionPool.Supervisor),
DBConnection.Watcher
]
Supervisor.start_link(children, strategy: :one_for_all, name: __MODULE__)
end
defp dynamic_supervisor(name) do
Supervisor.child_spec(
{DynamicSupervisor, name: name, strategy: :one_for_one},
id: name
)
end
end

View File

@@ -0,0 +1,111 @@
defmodule DBConnection.Backoff do
# This module provides a functional abstraction over backoffs with different types. It exposes
# a struct and a couple of functions to work with it.
@moduledoc false
@compile :nowarn_deprecated_function
alias DBConnection.Backoff
@default_type :rand_exp
@min 1_000
@max 30_000
@type t :: %__MODULE__{
type: :stop | :rand | :exp | :rand_exp,
min: non_neg_integer(),
max: non_neg_integer(),
state: term()
}
defstruct [:type, :min, :max, :state]
@spec new(keyword) :: t | nil
def new(opts) when is_list(opts) do
case Keyword.get(opts, :backoff_type, @default_type) do
:stop ->
nil
type ->
{min, max} = min_max(opts)
new(type, min, max)
end
end
@spec backoff(t) :: {non_neg_integer, t}
def backoff(backoff)
def backoff(%Backoff{type: :rand, min: min, max: max} = s) do
{rand(min, max), s}
end
def backoff(%Backoff{type: :exp, min: min, state: nil} = s) do
{min, %{s | state: min}}
end
def backoff(%Backoff{type: :exp, max: max, state: prev} = s) do
next = min(Bitwise.<<<(prev, 1), max)
{next, %{s | state: next}}
end
def backoff(%Backoff{type: :rand_exp, max: max, state: state} = s) do
{prev, lower} = state
next_min = min(prev, lower)
next_max = min(prev * 3, max)
next = rand(next_min, next_max)
{next, %{s | state: {next, lower}}}
end
@spec reset(t) :: t
def reset(backoff)
def reset(%Backoff{type: :rand} = s), do: s
def reset(%Backoff{type: :exp} = s), do: %{s | state: nil}
def reset(%Backoff{type: :rand_exp, min: min, state: {_, lower}} = s) do
%{s | state: {min, lower}}
end
## Internal
defp min_max(opts) do
case {opts[:backoff_min], opts[:backoff_max]} do
{nil, nil} -> {@min, @max}
{nil, max} -> {min(@min, max), max}
{min, nil} -> {min, max(min, @max)}
{min, max} -> {min, max}
end
end
defp new(_, min, _) when not (is_integer(min) and min >= 0) do
raise ArgumentError, "minimum #{inspect(min)} not 0 or a positive integer"
end
defp new(_, _, max) when not (is_integer(max) and max >= 0) do
raise ArgumentError, "maximum #{inspect(max)} not 0 or a positive integer"
end
defp new(_, min, max) when min > max do
raise ArgumentError, "minimum #{min} is greater than maximum #{max}"
end
defp new(:rand, min, max) do
%Backoff{type: :rand, min: min, max: max, state: nil}
end
defp new(:exp, min, max) do
%Backoff{type: :exp, min: min, max: max, state: nil}
end
defp new(:rand_exp, min, max) do
lower = max(min, div(max, 3))
%Backoff{type: :rand_exp, min: min, max: max, state: {min, lower}}
end
defp new(type, _, _) do
raise ArgumentError, "unknown type #{inspect(type)}"
end
defp rand(min, max) do
:rand.uniform(max - min + 1) + min - 1
end
end

View File

@@ -0,0 +1,536 @@
defmodule DBConnection.Connection do
@moduledoc false
@behaviour :gen_statem
require Logger
alias DBConnection.Backoff
alias DBConnection.Holder
alias DBConnection.Util
@timeout 15_000
@sensitive_opts [:parameters, :hostname, :port, :username, :password, :database]
@doc false
def start_link(mod, opts, pool, tag) do
start_opts = Keyword.take(opts, [:debug, :spawn_opt])
:gen_statem.start_link(__MODULE__, {mod, opts, pool, tag}, start_opts)
end
@doc false
def child_spec(mod, opts, pool, tag, child_opts) do
Supervisor.child_spec(
%{id: __MODULE__, start: {__MODULE__, :start_link, [mod, opts, pool, tag]}},
child_opts
)
end
@doc false
def disconnect({pid, ref}, err, state) do
:gen_statem.cast(pid, {:disconnect, ref, err, state})
end
@doc false
def stop({pid, ref}, err, state) do
:gen_statem.cast(pid, {:stop, ref, err, state})
end
@doc false
def ping({pid, ref}, state) do
:gen_statem.cast(pid, {:ping, ref, state})
end
## gen_statem API
@doc false
@impl :gen_statem
def callback_mode, do: :handle_event_function
@doc false
@impl :gen_statem
def init({mod, opts, pool, tag}) do
pool_index = Keyword.get(opts, :pool_index)
label = if pool_index, do: "db_conn_#{pool_index}", else: "db_conn"
Util.set_label(label)
s = %{
mod: mod,
opts: opts,
state: nil,
client: :closed,
pool: pool,
tag: tag,
timer: nil,
backoff: Backoff.new(opts),
connection_listeners: Keyword.get(opts, :connection_listeners, []),
after_connect: Keyword.get(opts, :after_connect),
after_connect_timeout: Keyword.get(opts, :after_connect_timeout, @timeout)
}
{:ok, :no_state, s, {:next_event, :internal, {:connect, :init}}}
end
@impl :gen_statem
def handle_event(type, info, state, s)
def handle_event(:internal, {:connect, _info}, :no_state, s) do
%{mod: mod, opts: opts, backoff: backoff, after_connect: after_connect} = s
try do
apply(mod, :connect, [connect_opts(opts)])
rescue
e ->
{e, stack} = maybe_sanitize_exception(e, __STACKTRACE__, opts)
reraise e, stack
else
{:ok, state} when after_connect != nil ->
ref = make_ref()
:gen_statem.cast(self(), {:after_connect, ref})
{:keep_state, %{s | state: state, client: {ref, :connect}}}
{:ok, state} ->
backoff = backoff && Backoff.reset(backoff)
ref = make_ref()
:gen_statem.cast(self(), {:connected, ref})
{:keep_state, %{s | state: state, client: {ref, :connect}, backoff: backoff}}
{:error, err} when is_nil(backoff) ->
Logger.error(
fn ->
[
inspect(mod),
" (",
Util.inspect_pid(self()),
") failed to connect: " | Exception.format_banner(:error, err, [])
]
end,
crash_reason: {err, []}
)
raise err
{:error, err} ->
Logger.error(
fn ->
[
inspect(mod),
?\s,
?(,
Util.inspect_pid(self()),
") failed to connect: "
| Exception.format_banner(:error, err, [])
]
end,
crash_reason: {err, []}
)
{timeout, backoff} = Backoff.backoff(backoff)
{:keep_state, %{s | backoff: backoff}, {{:timeout, :backoff}, timeout, nil}}
end
end
def handle_event(:internal, {:disconnect, {log, err}}, :no_state, %{mod: mod} = s) do
if log == :log do
severity =
case err do
%DBConnection.ConnectionError{severity: severity} -> severity
_ -> :error
end
Logger.log(severity, fn ->
[
inspect(mod),
?\s,
?(,
Util.inspect_pid(self()),
") disconnected: " | Exception.format_banner(:error, err, [])
]
end)
:ok
end
%{state: state, client: client, timer: timer, backoff: backoff} = s
demonitor(client)
cancel_timer(timer)
:ok = apply(mod, :disconnect, [err, state])
s = %{s | state: nil, client: :closed, timer: nil}
notify_connection_listeners(:disconnected, s)
case client do
_ when backoff == nil ->
{:stop, {:shutdown, err}, s}
{_, :after_connect} ->
{timeout, backoff} = Backoff.backoff(backoff)
{:keep_state, %{s | backoff: backoff}, {{:timeout, :backoff}, timeout, nil}}
_ ->
{:keep_state, s, {:next_event, :internal, {:connect, :disconnect}}}
end
end
def handle_event({:timeout, :backoff}, _content, :no_state, s) do
{:keep_state, s, {:next_event, :internal, {:connect, :backoff}}}
end
def handle_event(:cast, {:ping, ref, state}, :no_state, %{client: {ref, :pool}, mod: mod} = s) do
case apply(mod, :ping, [state]) do
{:ok, state} ->
pool_update(state, s)
{:disconnect, err, state} ->
{:keep_state, %{s | state: state}, {:next_event, :internal, {:disconnect, {:log, err}}}}
end
end
def handle_event(:cast, {:disconnect, ref, err, state}, :no_state, %{client: {ref, _}} = s) do
{:keep_state, %{s | state: state}, {:next_event, :internal, {:disconnect, {:log, err}}}}
end
def handle_event(:cast, {:stop, ref, err, state}, :no_state, %{client: {ref, _}} = s) do
{_, stack} = :erlang.process_info(self(), :current_stacktrace)
case err do
ok when ok in [:normal, :shutdown] ->
:ok
{:shutdown, _term} ->
:ok
_ ->
reason =
case err do
%{__exception__: true} -> Exception.format_banner(:error, err, stack)
_other -> "** #{inspect(err)}"
end
format =
~c"** State machine ~p terminating~n" ++
~c"** Reason for termination ==~n" ++
~c"~s~n"
:error_logger.format(format, [self(), reason])
end
{:stop, {err, stack}, %{s | state: state}}
end
def handle_event(:cast, {tag, _, _, _}, :no_state, s) when tag in [:disconnect, :stop] do
handle_timeout(s)
end
def handle_event(:cast, {:after_connect, ref}, :no_state, %{client: {ref, :connect}} = s) do
%{
mod: mod,
state: state,
after_connect: after_connect,
after_connect_timeout: timeout,
opts: opts
} = s
notify_connection_listeners(:connected, s)
case apply(mod, :checkout, [state]) do
{:ok, state} ->
opts = [timeout: timeout] ++ opts
opts = Keyword.drop(opts, @sensitive_opts)
{pid, ref} = DBConnection.Task.run_child(mod, state, after_connect, opts)
timer = start_timer(pid, timeout)
s = %{s | client: {ref, :after_connect}, timer: timer, state: state}
{:keep_state, s}
{:disconnect, err, state} ->
{:keep_state, %{s | state: state}, {:next_event, :internal, {:disconnect, {:log, err}}}}
end
end
def handle_event(:cast, {:after_connect, _}, :no_state, _s) do
:keep_state_and_data
end
def handle_event(:cast, {:connected, ref}, :no_state, %{client: {ref, :connect}} = s) do
%{mod: mod, state: state} = s
notify_connection_listeners(:connected, s)
case apply(mod, :checkout, [state]) do
{:ok, state} ->
pool_update(state, s)
{:disconnect, err, state} ->
{:keep_state, %{s | state: state}, {:next_event, :internal, {:disconnect, {:log, err}}}}
end
end
def handle_event(:cast, {:connected, _}, :no_state, _s) do
:keep_state_and_data
end
def handle_event(
:info,
{:DOWN, ref, _, pid, reason},
:no_state,
%{client: {ref, :after_connect}} = s
) do
message =
"client #{Util.inspect_pid(pid)} exited: " <> Exception.format_exit(reason)
err = DBConnection.ConnectionError.exception(message)
{:keep_state, %{s | client: {nil, :after_connect}},
{:next_event, :internal, {:disconnect, {down_log(reason), err}}}}
end
def handle_event(:info, {:DOWN, mon, _, pid, reason}, :no_state, %{client: {ref, mon}} = s) do
message =
"client #{Util.inspect_pid(pid)} exited: " <> Exception.format_exit(reason)
err = DBConnection.ConnectionError.exception(message)
{:keep_state, %{s | client: {ref, nil}},
{:next_event, :internal, {:disconnect, {down_log(reason), err}}}}
end
def handle_event(
:info,
{:timeout, timer, {__MODULE__, pid, timeout}},
:no_state,
%{timer: timer} = s
)
when is_reference(timer) do
message =
"client #{Util.inspect_pid(pid)} timed out because it checked out " <>
"the connection for longer than #{timeout}ms"
exc =
case Process.info(pid, :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
message <>
"\n\n#{Util.inspect_pid(pid)} was at location:\n\n" <>
Exception.format_stacktrace(stacktrace)
_ ->
message
end
|> DBConnection.ConnectionError.exception()
{:keep_state, %{s | timer: nil}, {:next_event, :internal, {:disconnect, {:log, exc}}}}
end
def handle_event(
:info,
{:"ETS-TRANSFER", holder, _pid, {msg, ref, extra}},
:no_state,
%{client: {ref, :after_connect}, timer: timer} = s
) do
{_, state} = Holder.delete(holder)
cancel_timer(timer)
s = %{s | timer: nil}
case msg do
:checkin -> handle_checkin(state, s)
:disconnect -> handle_event(:cast, {:disconnect, ref, extra, state}, :no_state, s)
:stop -> handle_event(:cast, {:stop, ref, extra, state}, :no_state, s)
end
end
# We discard EXIT messages which may arrive if the process is trapping exits
def handle_event(:info, {:EXIT, _, _}, :no_state, s) do
handle_timeout(s)
end
def handle_event(:info, msg, :no_state, %{mod: mod} = s) do
Logger.info(fn ->
[inspect(mod), ?\s, ?(, Util.inspect_pid(self()), ") missed message: " | inspect(msg)]
end)
handle_timeout(s)
end
@doc false
@impl :gen_statem
# If client is :closed then the connection was previously disconnected
# and cleanup is not required.
def terminate(_, _, %{client: :closed}), do: :ok
def terminate(reason, _, s) do
%{mod: mod, state: state} = s
msg = "connection exited: " <> Exception.format_exit(reason)
msg
|> DBConnection.ConnectionError.exception()
|> mod.disconnect(state)
end
@doc false
@impl :gen_statem
def format_status(info, [_, :no_state, %{client: :closed, mod: mod}]) do
case info do
:normal -> [{:data, [{~c"Module", mod}]}]
:terminate -> mod
end
end
def format_status(info, [pdict, :no_state, %{mod: mod, state: state}]) do
case function_exported?(mod, :format_status, 2) do
true when info == :normal ->
normal_status(mod, pdict, state)
false when info == :normal ->
normal_status_default(mod, state)
true when info == :terminate ->
{mod, terminate_status(mod, pdict, state)}
false when info == :terminate ->
{mod, state}
end
end
## Helpers
defp maybe_sanitize_exception(e, stack, opts) do
if Keyword.get(opts, :show_sensitive_data_on_connection_error, false) do
{e, stack}
else
message =
"connect raised #{inspect(e.__struct__)} exception#{sanitized_message(e)}. " <>
"The exception details are hidden, as they may contain sensitive data such as " <>
"database credentials. You may set :show_sensitive_data_on_connection_error " <>
"to true when starting your connection if you wish to see all of the details"
{RuntimeError.exception(message), cleanup_stacktrace(stack)}
end
end
defp sanitized_message(%KeyError{} = e), do: ": #{Exception.message(%{e | term: nil})}"
defp sanitized_message(_), do: ""
defp connect_opts(opts) do
case Keyword.get(opts, :configure) do
{mod, fun, args} ->
apply(mod, fun, [opts | args])
fun when is_function(fun, 1) ->
fun.(opts)
nil ->
opts
end
end
defp down_log(:normal), do: :nolog
defp down_log(:shutdown), do: :nolog
defp down_log({:shutdown, _}), do: :nolog
defp down_log(_), do: :log
defp handle_timeout(s), do: {:keep_state, s}
defp demonitor({_, mon}) when is_reference(mon) do
Process.demonitor(mon, [:flush])
end
defp demonitor({mon, :after_connect}) when is_reference(mon) do
Process.demonitor(mon, [:flush])
end
defp demonitor({_, _}), do: true
defp demonitor(nil), do: true
defp start_timer(_, :infinity), do: nil
defp start_timer(pid, timeout) do
:erlang.start_timer(timeout, self(), {__MODULE__, pid, timeout})
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(timer) do
case :erlang.cancel_timer(timer) do
false -> flush_timer(timer)
_ -> :ok
end
end
defp flush_timer(timer) do
receive do
{:timeout, ^timer, {__MODULE__, _, _}} ->
:ok
after
0 ->
raise ArgumentError, "timer #{inspect(timer)} does not exist"
end
end
defp handle_checkin(state, s) do
%{backoff: backoff, client: client} = s
backoff = backoff && Backoff.reset(backoff)
demonitor(client)
pool_update(state, %{s | client: nil, backoff: backoff})
end
defp pool_update(state, %{pool: pool, tag: tag, mod: mod} = s) do
case Holder.update(pool, tag, mod, state) do
{:ok, ref} ->
{:keep_state, %{s | client: {ref, :pool}, state: state}, :hibernate}
:error ->
{:stop, {:shutdown, :no_more_pool}, s}
end
end
defp normal_status(mod, pdict, state) do
try do
mod.format_status(:normal, [pdict, state])
catch
_, _ ->
normal_status_default(mod, state)
else
status ->
status
end
end
defp normal_status_default(mod, state) do
[{:data, [{~c"Module", mod}, {~c"State", state}]}]
end
defp terminate_status(mod, pdict, state) do
try do
mod.format_status(:terminate, [pdict, state])
catch
_, _ ->
state
else
status ->
status
end
end
defp cleanup_stacktrace(stack) do
case stack do
[{_, _, arity, _} | _rest] = stacktrace when is_integer(arity) ->
stacktrace
[{mod, fun, args, info} | rest] when is_list(args) ->
[{mod, fun, length(args), info} | rest]
end
end
defp notify_connection_listeners(action, %{} = state) do
%{connection_listeners: connection_listeners} = state
{listeners, message} =
case connection_listeners do
listeners when is_list(listeners) ->
{listeners, {action, self()}}
{listeners, tag} when is_list(listeners) ->
{listeners, {action, self(), tag}}
end
Enum.each(listeners, &send(&1, message))
end
end

View File

@@ -0,0 +1,24 @@
defmodule DBConnection.ConnectionError do
@moduledoc """
A generic connection error exception.
The raised exception might include the reason which would be useful
to programmatically determine what was causing the error.
"""
@typedoc since: "2.7.0"
@type t() :: %__MODULE__{
message: String.t(),
reason: :error | :queue_timeout,
severity: Logger.level()
}
defexception [:message, severity: :error, reason: :error]
@doc false
def exception(message, reason) when is_binary(message) and reason in [:error, :queue_timeout] do
message
|> exception()
|> Map.replace!(:reason, reason)
end
end

View File

@@ -0,0 +1,391 @@
defmodule DBConnection.ConnectionPool do
@moduledoc """
The default connection pool.
The queueing algorithm is based on [CoDel](https://queue.acm.org/appendices/codel.html).
You're not supposed to call any functions on this pool directly, but only pass this
as the value of the `:pool` option in functions such as `DBConnection.start_link/2`.
`disconnect_all/3`, which by default will result in connections being
reestablished, can be called periodically to recycle checked-in connections
after a maximum lifetime is reached. `Ecto SQL` users may find it at
https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.html#disconnect_all/3
"""
use GenServer
alias DBConnection.Holder
alias DBConnection.Util
@behaviour DBConnection.Pool
@queue_target 50
@queue_interval 2000
@idle_interval 1000
@time_unit 1000
@doc false
def start_link({mod, opts}) do
GenServer.start_link(__MODULE__, {mod, opts}, start_opts(opts))
end
@doc false
@impl DBConnection.Pool
def checkout(pool, callers, opts) do
Holder.checkout(pool, callers, opts)
end
@doc false
@impl DBConnection.Pool
def disconnect_all(pool, interval, _opts) do
GenServer.call(pool, {:disconnect_all, interval}, :infinity)
end
@doc false
@impl DBConnection.Pool
def get_connection_metrics(pool) do
GenServer.call(pool, :get_connection_metrics, :infinity)
end
## GenServer api
@impl GenServer
def init({mod, opts}) do
DBConnection.register_as_pool(mod)
queue = :ets.new(__MODULE__.Queue, [:protected, :ordered_set, decentralized_counters: true])
ts = {System.monotonic_time(), 0}
{:ok, _} = DBConnection.ConnectionPool.Pool.start_supervised(queue, mod, opts)
target = Keyword.get(opts, :queue_target, @queue_target)
interval = Keyword.get(opts, :queue_interval, @queue_interval)
idle_interval = Keyword.get(opts, :idle_interval, @idle_interval)
idle_limit = Keyword.get_lazy(opts, :idle_limit, fn -> Keyword.get(opts, :pool_size, 1) end)
now_in_native = System.monotonic_time()
now_in_ms = System.convert_time_unit(now_in_native, :native, @time_unit)
codel = %{
target: target,
interval: interval,
delay: 0,
slow: false,
next: now_in_ms,
poll: nil,
idle_interval: idle_interval,
idle_limit: idle_limit,
idle: nil
}
codel = start_idle(now_in_native, start_poll(now_in_ms, now_in_ms, codel))
{:ok, {:busy, queue, codel, ts}}
end
@impl GenServer
def handle_call(:get_connection_metrics, _from, {status, queue, _, _} = state) do
{ready_conn_count, checkout_queue_length} =
case status do
:busy ->
{0, :ets.select_count(queue, [{{{:_, :_, :_}}, [], [true]}])}
:ready ->
{:ets.select_count(queue, [{{{:_, :_}}, [], [true]}]), 0}
end
metrics = %{
source: {:pool, self()},
ready_conn_count: ready_conn_count,
checkout_queue_length: checkout_queue_length
}
{:reply, [metrics], state}
end
def handle_call({:disconnect_all, interval}, _from, {type, queue, codel, _ts}) do
ts = {System.monotonic_time(), interval}
{:reply, :ok, {type, queue, codel, ts}}
end
@impl GenServer
def handle_info(
{:db_connection, from, {:checkout, _caller, now, queue?}},
{:busy, queue, _, _} = busy
) do
case queue? do
true ->
:ets.insert(queue, {{now, System.unique_integer(), from}})
{:noreply, busy}
false ->
message = "connection not available and queuing is disabled"
err = DBConnection.ConnectionError.exception(message)
Holder.reply_error(from, err)
{:noreply, busy}
end
end
def handle_info(
{:db_connection, from, {:checkout, _caller, _now, _queue?}} = checkout,
{:ready, queue, _codel, _ts} = ready
) do
case :ets.first(queue) do
{queued_in_native, holder} = key ->
Holder.handle_checkout(holder, from, queue, queued_in_native) and :ets.delete(queue, key)
{:noreply, ready}
:"$end_of_table" ->
handle_info(checkout, put_elem(ready, 0, :busy))
end
end
def handle_info({:"ETS-TRANSFER", holder, pid, queue}, {_, queue, _, _} = data) do
message = "client #{Util.inspect_pid(pid)} exited"
err = DBConnection.ConnectionError.exception(message: message, severity: :info)
Holder.handle_disconnect(holder, err)
{:noreply, data}
end
def handle_info({:"ETS-TRANSFER", holder, _, {msg, queue, extra}}, {_, queue, _, ts} = data) do
case msg do
:checkin ->
owner = self()
case :ets.info(holder, :owner) do
^owner ->
{time, interval} = ts
if Holder.maybe_disconnect(holder, time, interval) do
{:noreply, data}
else
handle_checkin(holder, extra, data)
end
:undefined ->
{:noreply, data}
end
:disconnect ->
Holder.handle_disconnect(holder, extra)
{:noreply, data}
:stop ->
Holder.handle_stop(holder, extra)
{:noreply, data}
end
end
def handle_info({:timeout, deadline, {queue, holder, pid, len}}, {_, queue, _, _} = data) do
# Check that timeout refers to current holder (and not previous)
if Holder.handle_deadline(holder, deadline) do
message =
"client #{Util.inspect_pid(pid)} timed out because " <>
"it queued and checked out the connection for longer than #{len}ms"
exc =
case Process.info(pid, :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
message <>
"\n\n#{Util.inspect_pid(pid)} was at location:\n\n" <>
Exception.format_stacktrace(stacktrace)
_ ->
message
end
|> DBConnection.ConnectionError.exception()
Holder.handle_disconnect(holder, exc)
end
{:noreply, data}
end
def handle_info({:timeout, poll, {time, last_sent}}, {_, _, %{poll: poll}, _} = data) do
{status, queue, codel, ts} = data
# If no queue progress since last poll check queue
case :ets.first(queue) do
{sent, _, _} when sent <= last_sent and status == :busy ->
delay = time - sent
timeout(delay, time, queue, start_poll(time, sent, codel), ts)
{sent, _, _} ->
{:noreply, {status, queue, start_poll(time, sent, codel), ts}}
_ ->
{:noreply, {status, queue, start_poll(time, time, codel), ts}}
end
end
def handle_info({:timeout, idle, past_in_native}, {_, _, %{idle: idle}, _} = data) do
{status, queue, %{idle_limit: limit} = codel, ts} = data
drop_idle(past_in_native, limit, status, queue, codel, ts)
end
defp drop_idle(past_in_native, limit, status, queue, codel, ts) do
with true <- status == :ready and limit > 0,
{queued_in_native, holder} = key when queued_in_native <= past_in_native <-
:ets.first(queue) do
:ets.delete(queue, key)
Holder.maybe_disconnect(holder, elem(ts, 0), 0) or Holder.handle_ping(holder)
drop_idle(past_in_native, limit - 1, status, queue, codel, ts)
else
_ ->
{:noreply, {status, queue, start_idle(System.monotonic_time(), codel), ts}}
end
end
defp timeout(delay, time, queue, codel, ts) do
case codel do
%{delay: min_delay, next: next, target: target, interval: interval}
when time >= next and min_delay > target ->
codel = %{codel | slow: true, delay: delay, next: time + interval}
drop_slow(time, target * 2, queue)
{:noreply, {:busy, queue, codel, ts}}
%{next: next, interval: interval} when time >= next ->
codel = %{codel | slow: false, delay: delay, next: time + interval}
{:noreply, {:busy, queue, codel, ts}}
_ ->
{:noreply, {:busy, queue, codel, ts}}
end
end
defp drop_slow(time, timeout, queue) do
min_sent = time - timeout
match = {{:"$1", :_, :"$2"}}
guards = [{:<, :"$1", min_sent}]
select_slow = [{match, guards, [{{:"$1", :"$2"}}]}]
for {sent, from} <- :ets.select(queue, select_slow) do
drop(time - sent, from)
end
:ets.select_delete(queue, [{match, guards, [true]}])
end
defp handle_checkin(holder, now_in_native, {:ready, queue, _, _} = data) do
:ets.insert(queue, {{now_in_native, holder}})
{:noreply, data}
end
defp handle_checkin(holder, now_in_native, {:busy, queue, codel, ts}) do
now_in_ms = System.convert_time_unit(now_in_native, :native, @time_unit)
case dequeue(now_in_ms, holder, queue, codel, ts) do
{:busy, _, _, _} = busy ->
{:noreply, busy}
{:ready, _, _, _} = ready ->
:ets.insert(queue, {{now_in_native, holder}})
{:noreply, ready}
end
end
defp dequeue(time, holder, queue, codel, ts) do
case codel do
%{next: next, delay: delay, target: target} when time >= next ->
dequeue_first(time, delay > target, holder, queue, codel, ts)
%{slow: false} ->
dequeue_fast(time, holder, queue, codel, ts)
%{slow: true, target: target} ->
dequeue_slow(time, target * 2, holder, queue, codel, ts)
end
end
defp dequeue_first(time, slow?, holder, queue, codel, ts) do
%{interval: interval} = codel
next = time + interval
case :ets.first(queue) do
{sent, _, from} = key ->
:ets.delete(queue, key)
delay = time - sent
codel = %{codel | next: next, delay: delay, slow: slow?}
go(delay, from, time, holder, queue, codel, ts)
:"$end_of_table" ->
codel = %{codel | next: next, delay: 0, slow: slow?}
{:ready, queue, codel, ts}
end
end
defp dequeue_fast(time, holder, queue, codel, ts) do
case :ets.first(queue) do
{sent, _, from} = key ->
:ets.delete(queue, key)
go(time - sent, from, time, holder, queue, codel, ts)
:"$end_of_table" ->
{:ready, queue, %{codel | delay: 0}, ts}
end
end
defp dequeue_slow(time, timeout, holder, queue, codel, ts) do
case :ets.first(queue) do
{sent, _, from} = key when time - sent > timeout ->
:ets.delete(queue, key)
drop(time - sent, from)
dequeue_slow(time, timeout, holder, queue, codel, ts)
{sent, _, from} = key ->
:ets.delete(queue, key)
go(time - sent, from, time, holder, queue, codel, ts)
:"$end_of_table" ->
{:ready, queue, %{codel | delay: 0}, ts}
end
end
defp go(delay, from, time, holder, queue, %{delay: min} = codel, ts) do
case Holder.handle_checkout(holder, from, queue, 0) do
true when delay < min ->
{:busy, queue, %{codel | delay: delay}, ts}
true ->
{:busy, queue, codel, ts}
false ->
dequeue(time, holder, queue, codel, ts)
end
end
defp drop(delay, from) do
message = """
[#{ancestor()}] connection not available and request was dropped from queue after #{delay}ms. \
This means requests are coming in and your connection pool cannot serve them fast enough. \
You can address this by:
1. Ensuring your database is available and that you can connect to it
2. Tracking down slow queries and making sure they are running fast enough
3. Increasing the pool_size (although this increases resource consumption)
4. Allowing requests to wait longer by increasing :queue_target and :queue_interval
See DBConnection.start_link/2 for more information
"""
err = DBConnection.ConnectionError.exception(message, :queue_timeout)
Holder.reply_error(from, err)
end
defp ancestor do
Process.get(:"$ancestors", []) |> Enum.find(&is_atom/1)
end
defp start_opts(opts) do
Keyword.take(opts, [:name, :spawn_opt])
end
defp start_poll(now, last_sent, %{interval: interval} = codel) do
timeout = now + interval
poll = :erlang.start_timer(timeout, self(), {timeout, last_sent}, abs: true)
%{codel | poll: poll}
end
defp start_idle(now_in_native, %{idle_interval: interval} = codel) do
timeout = System.convert_time_unit(now_in_native, :native, :millisecond) + interval
idle = :erlang.start_timer(timeout, self(), now_in_native, abs: true)
%{codel | idle: idle}
end
end

View File

@@ -0,0 +1,33 @@
defmodule DBConnection.ConnectionPool.Pool do
@moduledoc false
use Supervisor, restart: :temporary
def start_supervised(tag, mod, opts) do
DBConnection.Watcher.watch(
DBConnection.ConnectionPool.Supervisor,
{DBConnection.ConnectionPool.Pool, {self(), tag, mod, opts}}
)
end
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg)
end
@impl true
def init({owner, tag, mod, opts}) do
size = Keyword.get(opts, :pool_size, 1)
if size < 1, do: raise(ArgumentError, "pool size must be greater or equal to 1, got #{size}")
children = for id <- 1..size, do: conn(owner, tag, id, mod, opts)
sup_opts = [strategy: :one_for_one] ++ Keyword.take(opts, [:max_restarts, :max_seconds])
Supervisor.init(children, sup_opts)
end
## Helpers
defp conn(owner, tag, id, mod, opts) do
child_opts = [id: {mod, owner, id}] ++ Keyword.take(opts, [:shutdown])
DBConnection.Connection.child_spec(mod, [pool_index: id] ++ opts, owner, tag, child_opts)
end
end

View File

@@ -0,0 +1,448 @@
defmodule DBConnection.Holder do
@moduledoc false
require Record
alias DBConnection.Util
@queue true
@timeout 15000
@time_unit 1000
Record.defrecord(:conn, [:connection, :module, :state, :lock, :ts, deadline: nil, status: :ok])
Record.defrecord(:pool_ref, [:pool, :reference, :deadline, :holder, :lock])
@type t :: :ets.tid()
@type checkin_time :: non_neg_integer() | nil
## Holder API
@spec new(pid, reference, module, term) :: t
def new(pool, ref, mod, state) do
# Insert before setting heir so that pool can't receive empty table
holder = :ets.new(__MODULE__, [:public, :ordered_set, decentralized_counters: true])
conn = conn(connection: self(), module: mod, state: state, ts: System.monotonic_time())
true = :ets.insert_new(holder, conn)
:ets.setopts(holder, {:heir, pool, ref})
holder
end
@spec update(pid, reference, module, term) :: {:ok, t} | :error
def update(pool, ref, mod, state) do
holder = new(pool, ref, mod, state)
try do
:ets.give_away(holder, pool, {:checkin, ref, System.monotonic_time()})
{:ok, holder}
rescue
ArgumentError -> :error
end
end
@spec delete(t) :: {module, term}
def delete(holder) do
[conn(module: module, state: state)] = :ets.lookup(holder, :conn)
:ets.delete(holder)
{module, state}
end
## Pool API (invoked by caller)
@callback checkout(pool :: GenServer.server(), [pid], opts :: Keyword.t()) ::
{:ok, pool_ref :: any, module, checkin_time, state :: any}
| {:error, Exception.t()}
def checkout(pool, callers, opts) do
queue? = Keyword.get(opts, :queue, @queue)
now = System.monotonic_time(@time_unit)
timeout = abs_timeout(now, opts)
case checkout(pool, callers, queue?, now, timeout) do
{:ok, _, _, _, _} = ok ->
ok
{:error, %DBConnection.ConnectionError{} = connection_error} = error ->
:telemetry.execute(
[:db_connection, :connection_error],
%{count: 1},
%{
error: connection_error,
opts: opts
}
)
error
{:error, _} = error ->
error
{:redirect, caller, proxy} ->
case checkout(proxy, [caller], opts) do
{:ok, _, _, _, _} = ok ->
ok
{:error, %DBConnection.ConnectionError{message: message} = exception} ->
{:error,
%{
exception
| message:
"could not checkout the connection owned by #{Util.inspect_pid(caller)}. " <>
"When using the sandbox, connections are shared, so this may imply " <>
"another process is using a connection. Reason: #{message}"
}}
{:error, _} = error ->
error
end
{:exit, reason} ->
exit({reason, {__MODULE__, :checkout, [pool, opts]}})
end
end
@spec checkin(pool_ref :: any) :: :ok
def checkin(pool_ref) do
# Note we may call checkin after a disconnect/stop. For this reason, we choose
# to not change the status on checkin but strictly speaking nobody can access
# the holder after disconnect/stop unless they store a copy of %DBConnection{}.
# Note status can't be :aborted as aborted is always reverted at the end of a
# transaction.
done(pool_ref, [{conn(:lock) + 1, nil}], :checkin, System.monotonic_time())
end
@spec disconnect(pool_ref :: any, err :: Exception.t()) :: :ok
def disconnect(pool_ref, err) do
done(pool_ref, [{conn(:status) + 1, :error}], :disconnect, err)
end
@spec stop(pool_ref :: any, err :: Exception.t()) :: :ok
def stop(pool_ref, err) do
done(pool_ref, [{conn(:status) + 1, :error}], :stop, err)
end
@spec handle(pool_ref :: any, fun :: atom, args :: [term], Keyword.t()) :: tuple
def handle(pool_ref, fun, args, opts) do
handle_or_cleanup(:handle, pool_ref, fun, args, opts)
end
@spec cleanup(pool_ref :: any, fun :: atom, args :: [term], Keyword.t()) :: tuple
def cleanup(pool_ref, fun, args, opts) do
handle_or_cleanup(:cleanup, pool_ref, fun, args, opts)
end
defp handle_or_cleanup(type, pool_ref, fun, args, opts) do
pool_ref(holder: holder, lock: lock) = pool_ref
try do
:ets.lookup(holder, :conn)
rescue
ArgumentError ->
msg = "connection is closed because of an error, disconnect or timeout"
{:disconnect, DBConnection.ConnectionError.exception(msg), _state = :unused}
else
[conn(lock: conn_lock)] when conn_lock != lock ->
raise "an outdated connection has been given to DBConnection on #{fun}/#{length(args) + 2}"
[conn(status: :error)] ->
msg = "connection is closed because of an error, disconnect or timeout"
{:disconnect, DBConnection.ConnectionError.exception(msg), _state = :unused}
[conn(status: :aborted)] when type != :cleanup ->
msg = "transaction rolling back"
{:disconnect, DBConnection.ConnectionError.exception(msg), _state = :unused}
[conn(module: module, state: state)] ->
holder_apply(holder, module, fun, args ++ [opts, state])
end
end
## Pool state helpers API (invoked by callers)
@spec put_state(pool_ref :: any, term) :: :ok
def put_state(pool_ref(holder: sink_holder), state) do
:ets.update_element(sink_holder, :conn, [{conn(:state) + 1, state}])
:ok
end
@spec status?(pool_ref :: any, :ok | :aborted) :: boolean()
def status?(pool_ref(holder: holder), status) do
try do
:ets.lookup_element(holder, :conn, conn(:status) + 1) == status
rescue
ArgumentError -> false
end
end
@spec put_status(pool_ref :: any, :ok | :aborted) :: boolean()
def put_status(pool_ref(holder: holder), status) do
try do
:ets.update_element(holder, :conn, [{conn(:status) + 1, status}])
rescue
ArgumentError -> false
end
end
## Pool callbacks (invoked by pools)
@spec reply_redirect({pid, reference}, pid | :shared | :auto, GenServer.server()) :: :ok
def reply_redirect(from, caller, redirect) do
GenServer.reply(from, {:redirect, caller, redirect})
:ok
end
@spec reply_error({pid, reference}, Exception.t()) :: :ok
def reply_error(from, exception) do
GenServer.reply(from, {:error, exception})
:ok
end
@spec handle_checkout(t, {pid, reference}, reference, checkin_time) :: boolean
def handle_checkout(holder, {pid, mref}, ref, checkin_time) do
:ets.give_away(holder, pid, {mref, ref, checkin_time})
rescue
ArgumentError ->
if Process.alive?(pid) or :ets.info(holder, :owner) != self() do
raise ArgumentError, no_holder(holder, pid)
else
false
end
end
@spec handle_deadline(t, reference) :: boolean
def handle_deadline(holder, deadline) do
:ets.lookup_element(holder, :conn, conn(:deadline) + 1)
rescue
ArgumentError -> false
else
^deadline -> true
_ -> false
end
@spec handle_ping(t) :: true
def handle_ping(holder) do
:ets.lookup(holder, :conn)
rescue
ArgumentError ->
raise ArgumentError, no_holder(holder, nil)
else
[conn(connection: conn, state: state)] ->
DBConnection.Connection.ping({conn, holder}, state)
:ets.delete(holder)
true
end
@spec handle_disconnect(t, Exception.t()) :: boolean
def handle_disconnect(holder, err) do
handle_done(holder, &DBConnection.Connection.disconnect/3, err)
end
@spec handle_stop(t, term) :: boolean
def handle_stop(holder, err) do
handle_done(holder, &DBConnection.Connection.stop/3, err)
end
@spec maybe_disconnect(t, integer, non_neg_integer) :: boolean()
def maybe_disconnect(holder, start, interval_ms) do
ts = :ets.lookup_element(holder, :conn, conn(:ts) + 1)
cond do
ts >= start ->
false
interval_ms == 0 ->
true
true ->
pid = :ets.lookup_element(holder, :conn, conn(:connection) + 1)
System.monotonic_time() > hash_pid(pid, interval_ms) + start
end
rescue
_ -> false
else
true ->
opts = [message: "disconnect_all requested", severity: :debug]
handle_disconnect(holder, DBConnection.ConnectionError.exception(opts))
false ->
false
end
## Private
defp checkout(pool, callers, queue?, start, timeout) do
case GenServer.whereis(pool) do
pid when node(pid) == node() ->
checkout_call(pid, callers, queue?, start, timeout)
pid when node(pid) != node() ->
{:exit, {:badnode, node(pid)}}
{_name, node} ->
{:exit, {:badnode, node}}
nil ->
{:exit, :noproc}
end
end
defp checkout_call(pid, callers, queue?, start, timeout) do
lock = Process.monitor(pid)
send(pid, {:db_connection, {self(), lock}, {:checkout, callers, start, queue?}})
receive do
{:"ETS-TRANSFER", holder, pool, {^lock, ref, checkin_time}} ->
Process.demonitor(lock, [:flush])
{deadline, ops} = start_deadline(timeout, pool, ref, holder, start)
:ets.update_element(holder, :conn, [{conn(:lock) + 1, lock} | ops])
pool_ref =
pool_ref(pool: pool, reference: ref, deadline: deadline, holder: holder, lock: lock)
checkout_result(holder, pool_ref, checkin_time)
{^lock, reply} ->
Process.demonitor(lock, [:flush])
reply
{:DOWN, ^lock, _, _, reason} ->
{:exit, reason}
end
end
defp checkout_result(holder, pool_ref, checkin_time) do
try do
:ets.lookup(holder, :conn)
rescue
ArgumentError ->
# Deadline could hit and be handled pool before using connection
msg = "connection not available because deadline reached while in queue"
{:error, DBConnection.ConnectionError.exception(msg)}
else
[conn(module: mod, state: state)] ->
{:ok, pool_ref, mod, checkin_time, state}
end
end
defp no_holder(holder, maybe_pid) do
reason =
case :ets.info(holder, :owner) do
:undefined -> "does not exist"
^maybe_pid -> "is being given to its current owner"
owner when owner != self() -> "does not belong to the giving process"
_ -> "could not be given away"
end
call_reason =
if maybe_pid do
"Error happened when attempting to transfer to #{Util.inspect_pid(maybe_pid)} " <>
"(alive: #{Process.alive?(maybe_pid)})"
else
"Error happened when looking up connection"
end
"""
#{inspect(__MODULE__)} #{inspect(holder)} #{reason}, pool inconsistent.
#{call_reason}.
SELF: #{Util.inspect_pid(self())}
ETS INFO: #{inspect(:ets.info(holder))}
Please report at https://github.com/elixir-ecto/db_connection/issues"
"""
end
defp holder_apply(holder, module, fun, args) do
try do
apply(module, fun, args)
catch
kind, reason ->
{:catch, kind, reason, __STACKTRACE__}
else
result when is_tuple(result) ->
state = :erlang.element(:erlang.tuple_size(result), result)
try do
:ets.update_element(holder, :conn, {conn(:state) + 1, state})
result
rescue
ArgumentError ->
augment_disconnect(result)
end
# If it is not a tuple, we just return it as is so we raise bad return.
result ->
result
end
end
defp augment_disconnect({:disconnect, %DBConnection.ConnectionError{} = err, state}) do
%{message: message} = err
message =
message <>
" (the connection was closed by the pool, " <>
"possibly due to a timeout or because the pool has been terminated)"
{:disconnect, %{err | message: message}, state}
end
defp augment_disconnect(result), do: result
defp done(pool_ref, ops, tag, info) do
pool_ref(pool: pool, reference: ref, deadline: deadline, holder: holder) = pool_ref
cancel_deadline(deadline)
try do
:ets.update_element(holder, :conn, [{conn(:deadline) + 1, nil} | ops])
:ets.give_away(holder, pool, {tag, ref, info})
rescue
ArgumentError -> :ok
else
true -> :ok
end
end
defp handle_done(holder, stop, err) do
:ets.lookup(holder, :conn)
rescue
ArgumentError ->
false
else
[conn(connection: pid, deadline: deadline, state: state)] ->
cancel_deadline(deadline)
:ets.delete(holder)
stop.({pid, holder}, err, state)
true
end
defp abs_timeout(now, opts) do
case Keyword.get(opts, :timeout, @timeout) do
:infinity -> Keyword.get(opts, :deadline)
timeout -> min(now + timeout, Keyword.get(opts, :deadline))
end
end
defp start_deadline(nil, _, _, _, _) do
{nil, []}
end
defp start_deadline(timeout, pid, ref, holder, start) do
deadline =
:erlang.start_timer(timeout, pid, {ref, holder, self(), timeout - start}, abs: true)
{deadline, [{conn(:deadline) + 1, deadline}]}
end
defp cancel_deadline(nil) do
:ok
end
defp cancel_deadline(deadline) do
:erlang.cancel_timer(deadline, async: true, info: false)
end
defp hash_pid(pid, interval_ms) do
hash = :erlang.phash2(pid, interval_ms)
System.convert_time_unit(hash, :millisecond, :native)
end
end

View File

@@ -0,0 +1,83 @@
defmodule DBConnection.LogEntry do
@moduledoc """
Struct containing log entry information.
See `t:t/0` for information on the fields.
"""
defstruct [
:call,
:query,
:params,
:result,
:pool_time,
:connection_time,
:decode_time,
:idle_time
]
@typedoc """
Log entry information.
* `:call` - The `DBConnection` function called
* `:query` - The query used by the function
* `:params` - The params passed to the function (if any)
* `:result` - The result of the call
* `:pool_time` - The length of time awaiting a connection from the pool (if
the connection was not already checked out)
* `:connection_time` - The length of time using the connection (if a
connection was used)
* `:decode_time` - The length of time decoding the result (if decoded the
result using `DBConnection.Query.decode/3`)
* `:idle_time` - The amount of time the connection was idle before use
All times are in the native time units of the VM, see
`System.monotonic_time/0`.
"""
@type t :: %__MODULE__{
call: atom,
query: any,
params: any,
result: {:ok, any} | {:ok, any, any} | {:error, Exception.t()},
pool_time: non_neg_integer | nil,
connection_time: non_neg_integer | nil,
idle_time: non_neg_integer | nil,
decode_time: non_neg_integer | nil
}
@doc false
def new(call, query, params, times, result) do
entry = %__MODULE__{call: call, query: query, params: params, result: result}
parse_times(times, entry)
end
## Helpers
defp parse_times([], entry), do: entry
defp parse_times(times, entry) do
stop = :erlang.monotonic_time()
{_, entry} = Enum.reduce(times, {stop, entry}, &parse_time/2)
entry
end
defp parse_time({:decode, start}, {stop, entry}) do
{start, %{entry | decode_time: stop - start}}
end
defp parse_time({:checkout, start}, {stop, entry}) do
{start, %{entry | pool_time: stop - start}}
end
defp parse_time({:checkin, start}, {stop, entry}) do
# The checkin time was most likely before checkout but it is
# not guaranteed as they are tracked by different processes.
# There should be no further measurements after checkin.
{stop, %{entry | idle_time: max(stop - start, 0)}}
end
defp parse_time({_, start}, {stop, entry}) do
%{connection_time: connection_time} = entry
{start, %{entry | connection_time: (connection_time || 0) + (stop - start)}}
end
end

View File

@@ -0,0 +1,155 @@
defmodule DBConnection.OwnershipError do
@moduledoc """
An exception for when errors with ownership occur.
"""
defexception [:message]
def exception(message), do: %DBConnection.OwnershipError{message: message}
end
defmodule DBConnection.Ownership do
@moduledoc """
A DBConnection pool that requires explicit checkout and checkin
as a mechanism to coordinate between processes.
## Options
* `:ownership_mode` - When mode is `:manual`, all connections must
be explicitly checked out before by using `ownership_checkout/2`.
Otherwise, mode is `:auto` and connections are checked out
implicitly. `{:shared, owner}` mode is also supported so
processes are allowed on demand. On all cases, checkins are
explicit via `ownership_checkin/2`. Defaults to `:auto`.
* `:ownership_timeout` - The maximum time (in milliseconds) that a process
is allowed to own a connection or `:infinity`, default `120_000`.
This timeout exists mostly for sanity checking purposes and can be increased
at will, since DBConnection automatically checks in connections whenever
there is a mode change.
* `:ownership_log` - The `Logger.level` to log ownership changes, or `nil`
not to log, default `nil`.
There are also two experimental options, `:post_checkout` and `:pre_checkin`
which allows a developer to configure what happens when a connection is
checked out and checked in. Those options are meant to be used during tests,
and have the following behaviour:
* `:post_checkout` - it must be an anonymous function that receives the
connection module, the connection state and it must return either
`{:ok, connection_module, connection_state}` or
`{:disconnect, err, connection_module, connection_state}`. This allows
the developer to change the connection module on post checkout. However,
in case of disconnects, the return `connection_module` must be the same
as the `connection_module` given. Defaults to simply returning the given
connection module and state.
* `:pre_checkin` - it must be an anonymous function that receives the
checkin reason (`:checkin`, `{:disconnect, err}` or `{:stop, err}`),
the connection module and the connection state returned by `post_checkout`.
It must return either `{:ok, connection_module, connection_state}` or
`{:disconnect, err, connection_module, connection_state}` where the connection
module is the module given to `:post_checkout` Defaults to simply returning
the given connection module and state.
## Callers lookup
When checking out, the ownership pool first looks if there is a connection
assigned to the current process and then checks if there is a connection
assigned to any of the processes listed under the `$callers` process
dictionary entry. The `$callers` entry is set by default for tasks from
Elixir v1.8.
You can also pass the `:caller` option on checkout with a pid and that
pid will be looked up first, instead of `self()`, and then we fall back
to `$callers`.
"""
alias DBConnection.Ownership.Manager
alias DBConnection.Holder
@behaviour DBConnection.Pool
@doc false
defdelegate child_spec(args), to: Manager
@doc false
@impl DBConnection.Pool
defdelegate disconnect_all(pool, interval, opts), to: Manager
@doc false
@impl DBConnection.Pool
def checkout(pool, callers, opts) do
case Manager.proxy_for(callers, opts) do
{caller, pool} -> Holder.checkout(pool, [caller], opts)
nil -> Holder.checkout(pool, callers, opts)
end
end
@doc false
@impl DBConnection.Pool
defdelegate get_connection_metrics(pool), to: Manager
@doc """
Explicitly checks a connection out from the ownership manager.
It may return `:ok` if the connection is checked out.
`{:already, :owner | :allowed}` if the caller process already
has a connection, or raise if there was an error.
"""
@spec ownership_checkout(GenServer.server(), Keyword.t()) ::
:ok | {:already, :owner | :allowed}
def ownership_checkout(manager, opts) do
with {:ok, pid} <- Manager.checkout(manager, opts) do
case Holder.checkout(pid, [self()], opts) do
{:ok, pool_ref, _module, _idle_time, _state} ->
Holder.checkin(pool_ref)
{:error, err} ->
raise err
end
end
end
@doc """
Changes the ownership mode.
`mode` may be `:auto`, `:manual` or `{:shared, owner}`.
The operation will always succeed when setting the mode to
`:auto` or `:manual`. It may fail with reason `:not_owner`
or `:not_found` when setting `{:shared, pid}` and the
given pid does not own any connection. May return
`:already_shared` if another process set the ownership
mode to `{:shared, _}` and is still alive.
"""
@spec ownership_mode(GenServer.server(), :auto | :manual | {:shared, pid}, Keyword.t()) ::
:ok | :already_shared | :not_owner | :not_found
defdelegate ownership_mode(manager, mode, opts), to: Manager, as: :mode
@doc """
Checks a connection back in.
A connection can only be checked back in by its owner.
"""
@spec ownership_checkin(GenServer.server(), Keyword.t()) ::
:ok | :not_owner | :not_found
defdelegate ownership_checkin(manager, opts), to: Manager, as: :checkin
@doc """
Allows the process given by `allow` to use the connection checked out
by `owner_or_allowed`.
It may return `:ok` if the connection is checked out.
`{:already, :owner | :allowed}` if the `allow` process already
has a connection. `owner_or_allowed` may either be the owner or any
other allowed process. Returns `:not_found` if the given process
does not have any connection checked out.
Setting the `unallow_existing` option to `true` will remove the process given by `allow` from
any existing allowance it may have (this is necessary because a given process can only be
allowed on a single connection at a time).
"""
@spec ownership_allow(GenServer.server(), owner_or_allowed :: pid, allow :: pid, Keyword.t()) ::
:ok | {:already, :owner | :allowed} | :not_found
defdelegate ownership_allow(manager, owner, allow, opts), to: Manager, as: :allow
end

View File

@@ -0,0 +1,439 @@
defmodule DBConnection.Ownership.Manager do
@moduledoc false
use GenServer
require Logger
alias DBConnection.Ownership.Proxy
alias DBConnection.Util
@timeout 5_000
@callback start_link({module, opts :: Keyword.t()}) ::
GenServer.on_start()
def start_link({module, opts}) do
{owner_opts, pool_opts} = Keyword.split(opts, [:name])
GenServer.start_link(__MODULE__, {module, owner_opts, pool_opts}, owner_opts)
end
@callback disconnect_all(GenServer.server(), non_neg_integer, Keyword.t()) :: :ok
def disconnect_all(pool, interval, opts) do
inner_pool = GenServer.call(pool, :pool, :infinity)
DBConnection.ConnectionPool.disconnect_all(inner_pool, interval, opts)
end
@spec proxy_for(callers :: [pid], Keyword.t()) :: {caller :: pid, proxy :: pid} | nil
def proxy_for(callers, opts) do
case Keyword.fetch(opts, :name) do
{:ok, name} ->
Enum.find_value(callers, &List.first(:ets.lookup(name, &1)))
:error ->
nil
end
end
@spec checkout(GenServer.server(), Keyword.t()) ::
{:ok, pid} | {:already, :owner | :allowed}
def checkout(manager, opts) do
GenServer.call(manager, {:checkout, opts}, :infinity)
end
@spec checkin(GenServer.server(), Keyword.t()) ::
:ok | :not_owner | :not_found
def checkin(manager, opts) do
timeout = Keyword.get(opts, :timeout, @timeout)
GenServer.call(manager, :checkin, timeout)
end
@spec mode(GenServer.server(), :auto | :manual | {:shared, pid}, Keyword.t()) ::
:ok | :already_shared | :not_owner | :not_found
def mode(manager, mode, opts)
when mode in [:auto, :manual]
when elem(mode, 0) == :shared and is_pid(elem(mode, 1)) do
timeout = Keyword.get(opts, :timeout, @timeout)
GenServer.call(manager, {:mode, mode}, timeout)
end
@spec allow(GenServer.server(), parent :: pid, allow :: pid, Keyword.t()) ::
:ok | {:already, :owner | :allowed} | :not_found
def allow(manager, parent, allow, opts) do
timeout = Keyword.get(opts, :timeout, @timeout)
passed_opts = Keyword.take(opts, [:unallow_existing])
GenServer.call(manager, {:allow, parent, allow, passed_opts}, timeout)
end
@spec get_connection_metrics(GenServer.server()) ::
{:ok, [DBConnection.Pool.connection_metrics()]} | :error
def get_connection_metrics(manager) do
GenServer.call(manager, :get_connection_metrics, :infinity)
end
## Callbacks
@impl true
def init({module, owner_opts, pool_opts}) do
DBConnection.register_as_pool(module)
ets =
case Keyword.fetch(owner_opts, :name) do
{:ok, name} when is_atom(name) ->
:ets.new(name, [
:set,
:named_table,
:protected,
read_concurrency: true,
decentralized_counters: true
])
_ ->
nil
end
# We can only start the connection pool directly because
# neither the pool's GenServer nor the manager trap exits.
# Otherwise we would need a supervisor plus a watcher process.
pool_opts = Keyword.delete(pool_opts, :pool)
{:ok, pool} = DBConnection.start_link(module, pool_opts)
log = Keyword.get(pool_opts, :ownership_log, nil)
mode = Keyword.get(pool_opts, :ownership_mode, :auto)
checkout_opts = Keyword.take(pool_opts, [:ownership_timeout, :queue_target, :queue_interval])
{:ok,
%{
pool: pool,
checkouts: %{},
owners: %{},
checkout_opts: checkout_opts,
mode: mode,
mode_ref: nil,
ets: ets,
log: log
}}
end
@impl true
def handle_call(:get_connection_metrics, _from, %{pool: pool, owners: owners, log: log} = state) do
pool_metrics = DBConnection.ConnectionPool.get_connection_metrics(pool)
proxy_metrics =
owners
|> Enum.map(fn {_, {proxy, _, _}} ->
try do
GenServer.call(proxy, :get_connection_metrics)
catch
:exit, reason ->
if log do
Logger.log(
log,
"Caught :exit while calling :get_connection_metrics due to #{inspect(reason)}"
)
end
nil
end
end)
|> Enum.reject(&is_nil/1)
{:reply, pool_metrics ++ proxy_metrics, state}
end
def handle_call(:pool, _from, %{pool: pool} = state) do
{:reply, pool, state}
end
def handle_call({:mode, {:shared, shared}}, {caller, _}, %{mode: {:shared, current}} = state) do
cond do
shared == current ->
{:reply, :ok, state}
Process.alive?(current) ->
{:reply, :already_shared, state}
true ->
share_and_reply(state, shared, caller)
end
end
def handle_call({:mode, {:shared, shared}}, {caller, _}, state) do
share_and_reply(state, shared, caller)
end
def handle_call({:mode, mode}, _from, %{mode: mode} = state) do
{:reply, :ok, state}
end
def handle_call({:mode, mode}, {caller, _}, state) do
state = proxy_checkin_all_except(state, [], caller)
{:reply, :ok, %{state | mode: mode, mode_ref: nil}}
end
def handle_call(:checkin, {caller, _}, state) do
{reply, state} = proxy_checkin(state, caller, caller)
{:reply, reply, state}
end
def handle_call({:allow, caller, allow, opts}, _from, %{checkouts: checkouts} = state) do
unallow_existing = Keyword.get(opts, :unallow_existing, false)
kind = already_checked_out(checkouts, allow)
if !unallow_existing && kind do
{:reply, {:already, kind}, state}
else
case Map.get(checkouts, caller, :not_found) do
{:owner, ref, proxy} ->
state =
if unallow_existing, do: owner_unallow(state, caller, allow, ref, proxy), else: state
{:reply, :ok, owner_allow(state, caller, allow, ref, proxy)}
{:allowed, ref, proxy} ->
state =
if unallow_existing, do: owner_unallow(state, caller, allow, ref, proxy), else: state
{:reply, :ok, owner_allow(state, caller, allow, ref, proxy)}
:not_found ->
{:reply, :not_found, state}
end
end
end
def handle_call({:checkout, opts}, {caller, _}, %{checkouts: checkouts} = state) do
if kind = already_checked_out(checkouts, caller) do
{:reply, {:already, kind}, state}
else
{proxy, state} = proxy_checkout(state, caller, opts)
{:reply, {:ok, proxy}, state}
end
end
@impl true
def handle_info({:db_connection, from, {:checkout, callers, _now, queue?}}, state) do
%{checkouts: checkouts, mode: mode, checkout_opts: checkout_opts} = state
caller = find_caller(callers, checkouts, mode)
case Map.get(checkouts, caller, :not_found) do
{status, _ref, proxy} when status in [:owner, :allowed] ->
DBConnection.Holder.reply_redirect(from, caller, proxy)
{:noreply, state}
:not_found when mode == :auto ->
{proxy, state} = proxy_checkout(state, caller, [queue: queue?] ++ checkout_opts)
DBConnection.Holder.reply_redirect(from, caller, proxy)
{:noreply, state}
:not_found when mode == :manual ->
not_found(from, mode)
{:noreply, state}
:not_found ->
{:shared, shared} = mode
{:owner, _ref, proxy} = Map.fetch!(checkouts, shared)
DBConnection.Holder.reply_redirect(from, shared, proxy)
{:noreply, state}
end
end
def handle_info({:DOWN, ref, _, _, _}, state) do
{:noreply, state |> owner_down(ref) |> unshare(ref)}
end
def handle_info(_msg, state) do
{:noreply, state}
end
defp already_checked_out(checkouts, pid) do
case Map.get(checkouts, pid, :not_found) do
{:owner, _, _} -> :owner
{:allowed, _, _} -> :allowed
:not_found -> nil
end
end
defp proxy_checkout(state, caller, opts) do
%{pool: pool, checkouts: checkouts, owners: owners, ets: ets, log: log, mode: mode} = state
{:ok, proxy} =
DynamicSupervisor.start_child(
DBConnection.Ownership.Supervisor,
{DBConnection.Ownership.Proxy, {caller, pool, opts}}
)
if log do
Logger.log(log, fn ->
[
Util.inspect_pid(caller),
" checked out connection in ",
inspect(mode),
" mode using proxy ",
Util.inspect_pid(proxy)
]
end)
end
ref = Process.monitor(proxy)
checkouts = Map.put(checkouts, caller, {:owner, ref, proxy})
owners = Map.put(owners, ref, {proxy, caller, []})
ets && :ets.insert(ets, {caller, proxy})
{proxy, %{state | checkouts: checkouts, owners: owners}}
end
defp proxy_checkin(state, maybe_owner, caller) do
case get_and_update_in(state.checkouts, &Map.pop(&1, maybe_owner, :not_found)) do
{{:owner, ref, proxy}, state} ->
Proxy.stop(proxy, caller)
{:ok, state |> owner_down(ref) |> unshare(ref)}
{{:allowed, _, _}, _} ->
{:not_owner, state}
{:not_found, _} ->
{:not_found, state}
end
end
defp proxy_checkin_all_except(state, except, caller) do
Enum.reduce(state.checkouts, state, fn {pid, _}, state ->
if pid in except do
state
else
{_, state} = proxy_checkin(state, pid, caller)
state
end
end)
end
defp owner_allow(%{ets: ets, log: log} = state, caller, allow, ref, proxy) do
if log do
Logger.log(log, fn ->
[
Util.inspect_pid(allow),
" was allowed by ",
Util.inspect_pid(caller),
" on proxy ",
Util.inspect_pid(proxy)
]
end)
end
state = put_in(state.checkouts[allow], {:allowed, ref, proxy})
state =
update_in(state.owners[ref], fn {proxy, caller, allowed} ->
{proxy, caller, [allow | List.delete(allowed, allow)]}
end)
ets && :ets.insert(ets, {allow, proxy})
state
end
defp owner_unallow(%{ets: ets, log: log} = state, caller, unallow, ref, proxy) do
if log do
Logger.log(log, fn ->
[
Util.inspect_pid(unallow),
" was unallowed by ",
Util.inspect_pid(caller),
" on proxy ",
Util.inspect_pid(proxy)
]
end)
end
state = update_in(state.checkouts, &Map.delete(&1, unallow))
state =
update_in(state.owners[ref], fn {proxy, caller, allowed} ->
{proxy, caller, List.delete(allowed, unallow)}
end)
ets && :ets.delete(ets, {unallow, proxy})
state
end
defp owner_down(%{ets: ets, log: log} = state, ref) do
case get_and_update_in(state.owners, &Map.pop(&1, ref)) do
{{proxy, caller, allowed}, state} ->
Process.demonitor(ref, [:flush])
entries = [caller | allowed]
if log do
Logger.log(log, fn ->
[
Enum.map_join(entries, ", ", &Util.inspect_pid/1),
" lost connection from proxy ",
Util.inspect_pid(proxy)
]
end)
end
ets && Enum.each(entries, &:ets.delete(ets, &1))
update_in(state.checkouts, &Map.drop(&1, entries))
{nil, state} ->
state
end
end
defp share_and_reply(%{checkouts: checkouts} = state, shared, caller) do
case Map.get(checkouts, shared, :not_found) do
{:owner, ref, _} ->
state = proxy_checkin_all_except(state, [shared], caller)
{:reply, :ok, %{state | mode: {:shared, shared}, mode_ref: ref}}
{:allowed, _, _} ->
{:reply, :not_owner, state}
:not_found ->
{:reply, :not_found, state}
end
end
defp unshare(%{mode_ref: ref} = state, ref) do
%{state | mode: :manual, mode_ref: nil}
end
defp unshare(state, _ref) do
state
end
defp find_caller(callers, checkouts, :manual) do
Enum.find(callers, &Map.has_key?(checkouts, &1)) || hd(callers)
end
defp find_caller([caller | _], _checkouts, _mode) do
caller
end
defp not_found({pid, _} = from, mode) do
msg = """
cannot find ownership process for #{Util.inspect_pid(pid)}
using mode #{inspect(mode)}.
When using ownership, you must manage connections in one
of the four ways:
* By explicitly checking out a connection
* By explicitly allowing a spawned process
* By running the pool in shared mode
* By using :caller option with allowed process
The first two options require every new process to explicitly
check a connection out or be allowed by calling checkout or
allow respectively.
The third option requires a {:shared, pid} mode to be set.
If using shared mode in tests, make sure your tests are not
async.
The fourth option requires [caller: pid] to be used when
checking out a connection from the pool. The caller process
should already be allowed on a connection.
If you are reading this error, it means you have not done one
of the steps above or that the owner process has crashed.
"""
DBConnection.Holder.reply_error(from, DBConnection.OwnershipError.exception(msg))
end
end

View File

@@ -0,0 +1,334 @@
defmodule DBConnection.Ownership.Proxy do
@moduledoc false
alias DBConnection.Holder
alias DBConnection.Util
use GenServer, restart: :temporary
@time_unit 1000
@ownership_timeout 120_000
@queue_target 50
@queue_interval 1000
def start_link({caller, pool, pool_opts}) do
GenServer.start_link(__MODULE__, {caller, pool, pool_opts}, [])
end
def stop(proxy, caller) do
GenServer.cast(proxy, {:stop, caller})
end
# Callbacks
@impl true
def init({caller, pool, pool_opts}) do
pool_opts =
pool_opts
|> Keyword.put(:timeout, :infinity)
|> Keyword.delete(:deadline)
owner_ref = Process.monitor(caller)
ownership_timeout = Keyword.get(pool_opts, :ownership_timeout, @ownership_timeout)
timeout = Keyword.get(pool_opts, :queue_target, @queue_target) * 2
interval = Keyword.get(pool_opts, :queue_interval, @queue_interval)
pre_checkin = Keyword.get(pool_opts, :pre_checkin, fn _, mod, state -> {:ok, mod, state} end)
post_checkout = Keyword.get(pool_opts, :post_checkout, &{:ok, &1, &2})
state = %{
client: nil,
timer: nil,
holder: nil,
timeout: timeout,
interval: interval,
poll: nil,
owner: {caller, owner_ref},
pool: pool,
pool_ref: nil,
pool_opts: pool_opts,
queue: :queue.new(),
mod: nil,
pre_checkin: pre_checkin,
post_checkout: post_checkout,
ownership_timer: start_timer(caller, ownership_timeout)
}
now = System.monotonic_time(@time_unit)
{:ok, start_poll(now, state)}
end
@impl true
def handle_info({:DOWN, ref, _, pid, _reason}, %{owner: {_, ref}} = state) do
shutdown("owner #{Util.inspect_pid(pid)} exited", state)
end
def handle_info({:timeout, deadline, {_ref, holder, pid, len}}, %{holder: holder} = state) do
if Holder.handle_deadline(holder, deadline) do
message =
"client #{Util.inspect_pid(pid)} timed out because " <>
"it queued and checked out the connection for longer than #{len}ms"
shutdown(message, state)
else
{:noreply, state}
end
end
def handle_info(
{:timeout, timer, {__MODULE__, pid, timeout}},
%{ownership_timer: timer} = state
) do
message =
"owner #{Util.inspect_pid(pid)} timed out because " <>
"it owned the connection for longer than #{timeout}ms (set via the :ownership_timeout option)"
# We don't invoke shutdown because this is always a disconnect, even if there is no client.
# On the other hand, those timeouts are unlikely to trigger, as it defaults to 2 mins.
pool_disconnect(DBConnection.ConnectionError.exception(message), false, state)
end
def handle_info({:timeout, poll, time}, %{poll: poll} = state) do
state = timeout(time, state)
{:noreply, start_poll(time, state)}
end
def handle_info(
{:db_connection, from, {:checkout, _caller, _now, _queue?}},
%{holder: nil} = state
) do
%{pool: pool, pool_opts: pool_opts, owner: {_, owner_ref}, post_checkout: post_checkout} =
state
case Holder.checkout(pool, [self()], pool_opts) do
{:ok, pool_ref, original_mod, _idle_time, conn_state} ->
case post_checkout.(original_mod, conn_state) do
{:ok, conn_mod, conn_state} ->
holder = Holder.new(self(), owner_ref, conn_mod, conn_state)
state = %{state | pool_ref: pool_ref, holder: holder, mod: original_mod}
checkout(from, state)
{:disconnect, err, ^original_mod, _conn_state} ->
Holder.disconnect(pool_ref, err)
Holder.reply_error(from, err)
{:stop, {:shutdown, err}, state}
end
{:error, err} ->
Holder.reply_error(from, err)
{:stop, {:shutdown, err}, state}
end
end
def handle_info(
{:db_connection, from, {:checkout, _caller, _now, _queue?}},
%{client: nil} = state
) do
checkout(from, state)
end
def handle_info({:db_connection, from, {:checkout, _caller, now, queue?}}, state) do
if queue? do
%{queue: queue} = state
queue = :queue.in({now, from}, queue)
{:noreply, %{state | queue: queue}}
else
message = "connection not available and queuing is disabled"
err = DBConnection.ConnectionError.exception(message)
Holder.reply_error(from, err)
{:noreply, state}
end
end
def handle_info(
{:"ETS-TRANSFER", holder, _, {msg, ref, extra}},
%{holder: holder, client: {_, ref, _}} = state
) do
case msg do
:checkin -> checkin(state)
:disconnect -> pool_disconnect(extra, true, state)
:stop -> pool_stop(extra, state)
end
end
def handle_info({:"ETS-TRANSFER", holder, pid, ref}, %{holder: holder, owner: {_, ref}} = state) do
shutdown("client #{Util.inspect_pid(pid)} exited", state)
end
@impl true
def handle_cast({:stop, caller}, %{owner: {owner, _}} = state) do
message =
"#{Util.inspect_pid(caller)} checked in the connection owned by #{Util.inspect_pid(owner)}"
message =
case pruned_stacktrace(caller) do
[] ->
message
current_stack ->
message <>
"\n\n#{Util.inspect_pid(caller)} triggered the checkin at location:\n\n" <>
Exception.format_stacktrace(current_stack)
end
shutdown(message, state)
end
@impl true
def handle_call(
:get_connection_metrics,
_,
%{queue: queue, holder: holder, client: client} = state
) do
connection_metrics = %{
source: {:proxy, self()},
ready_conn_count:
if is_nil(holder) or not is_nil(client) do
0
else
1
end,
checkout_queue_length: :queue.len(queue)
}
{:reply, connection_metrics, state}
end
defp checkout({pid, ref} = from, %{holder: holder} = state) do
if Holder.handle_checkout(holder, from, ref, nil) do
{:noreply, %{state | client: {pid, ref, pruned_stacktrace(pid)}}}
else
next(state)
end
end
defp checkin(state) do
next(%{state | client: nil})
end
defp next(%{queue: queue} = state) do
case :queue.out(queue) do
{{:value, {_, from}}, queue} ->
checkout(from, %{state | queue: queue})
{:empty, queue} ->
{:noreply, %{state | queue: queue}}
end
end
defp start_timer(_, :infinity), do: nil
defp start_timer(pid, timeout) do
:erlang.start_timer(timeout, self(), {__MODULE__, pid, timeout})
end
# If shutting down but it has no client, checkin
defp shutdown(reason, %{client: nil} = state) do
pool_checkin(reason, state)
end
# If shutting down but it has a client, disconnect
defp shutdown(reason, %{client: {client, _, checkout_stack}} = state) do
reason =
case pruned_stacktrace(client) do
[] ->
reason
current_stack ->
reason <>
"""
\n\nClient #{Util.inspect_pid(client)} is still using a connection from owner at location:
#{Exception.format_stacktrace(current_stack)}
The connection itself was checked out by #{Util.inspect_pid(client)} at location:
#{Exception.format_stacktrace(checkout_stack)}
"""
end
err = DBConnection.ConnectionError.exception(reason)
pool_disconnect(err, false, state)
end
## Helpers
defp pool_checkin(reason, state) do
checkin = fn pool_ref, _ -> Holder.checkin(pool_ref) end
pool_done(reason, state, :checkin, false, checkin, &Holder.disconnect/2)
end
defp pool_disconnect(err, keep_alive?, state) do
disconnect = &Holder.disconnect/2
pool_done(err, state, {:disconnect, err}, keep_alive?, disconnect, disconnect)
end
defp pool_stop(err, state) do
stop = &Holder.stop/2
pool_done(err, state, {:stop, err}, false, stop, stop)
end
defp pool_done(err, state, op, keep_alive?, done, stop_or_disconnect) do
%{holder: holder, pool_ref: pool_ref, pre_checkin: pre_checkin, mod: original_mod} = state
if holder do
{conn_mod, conn_state} = Holder.delete(holder)
case pre_checkin.(op, conn_mod, conn_state) do
{:ok, ^original_mod, conn_state} ->
Holder.put_state(pool_ref, conn_state)
done.(pool_ref, err)
if keep_alive? do
{:noreply, %{state | holder: nil}}
else
{:stop, {:shutdown, err}, state}
end
{:disconnect, err, ^original_mod, conn_state} ->
Holder.put_state(pool_ref, conn_state)
stop_or_disconnect.(pool_ref, err)
{:stop, {:shutdown, err}, state}
end
else
{:stop, {:shutdown, err}, state}
end
end
defp start_poll(now, %{interval: interval} = state) do
timeout = now + interval
poll = :erlang.start_timer(timeout, self(), timeout, abs: true)
%{state | poll: poll}
end
defp timeout(time, %{queue: queue, timeout: timeout} = state) do
case :queue.out(queue) do
{{:value, {sent, from}}, queue} when sent + timeout < time ->
drop(time - sent, from)
timeout(time, %{state | queue: queue})
{_, _} ->
state
end
end
defp drop(delay, from) do
message =
"connection not available and request was dropped from queue after #{delay}ms. " <>
"You can configure how long requests wait in the queue using :queue_target and " <>
":queue_interval. See DBConnection.start_link/2 for more information"
err = DBConnection.ConnectionError.exception(message, :queue_timeout)
Holder.reply_error(from, err)
end
@prune_modules [:gen, GenServer, DBConnection, DBConnection.Holder, DBConnection.Ownership]
defp pruned_stacktrace(pid) do
case Process.info(pid, :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
Enum.drop_while(stacktrace, &match?({mod, _, _, _} when mod in @prune_modules, &1))
_ ->
[]
end
end
end

View File

@@ -0,0 +1,19 @@
defmodule DBConnection.Pool do
@moduledoc false
@type pool :: GenServer.server()
@type connection_metrics :: %{
source: {:pool | :proxy, pid()},
ready_conn_count: non_neg_integer(),
checkout_queue_length: non_neg_integer()
}
@callback disconnect_all(pool, interval :: term, options :: keyword) :: :ok
@callback checkout(pool, callers :: [pid], options :: keyword) ::
{:ok, pool_ref :: term, module, checkin_time :: non_neg_integer() | nil,
state :: term}
| {:error, Exception.t()}
@callback get_connection_metrics(pool :: pool()) :: [connection_metrics()]
end

View File

@@ -0,0 +1,57 @@
defprotocol DBConnection.Query do
@moduledoc """
The `DBConnection.Query` protocol is responsible for preparing and
encoding queries.
All `DBConnection.Query` functions are executed in the caller process which
means it's safe to, for example, raise exceptions or do blocking calls as
they won't affect the connection process.
"""
@doc """
Parse a query.
This function is called to parse a query term before it is prepared using a
connection callback module.
See `DBConnection.prepare/3`.
"""
@spec parse(any, Keyword.t()) :: any
def parse(query, opts)
@doc """
Describe a query.
This function is called to describe a query after it is prepared using a
connection callback module.
See `DBConnection.prepare/3`.
"""
@spec describe(any, Keyword.t()) :: any
def describe(query, opts)
@doc """
Encode parameters using a query.
This function is called to encode a query before it is executed using a
connection callback module.
If this function raises `DBConnection.EncodeError`, then the query is
prepared once again.
See `DBConnection.execute/3`.
"""
@spec encode(any, any, Keyword.t()) :: any
def encode(query, params, opts)
@doc """
Decode a result using a query.
This function is called to decode a result after it is returned by a
connection callback module.
See `DBConnection.execute/3`.
"""
@spec decode(any, any, Keyword.t()) :: any
def decode(query, result, opts)
end

View File

@@ -0,0 +1,47 @@
defmodule DBConnection.Task do
@moduledoc false
@name __MODULE__
require DBConnection.Holder
def run_child(mod, state, fun, opts) do
arg = [fun, self(), opts]
{:ok, pid} = Task.Supervisor.start_child(@name, __MODULE__, :init, arg)
ref = Process.monitor(pid)
_ = DBConnection.Holder.update(pid, ref, mod, state)
{pid, ref}
end
def init(fun, parent, opts) do
try do
Process.link(parent)
catch
:error, :noproc ->
exit({:shutdown, :noproc})
end
receive do
{:"ETS-TRANSFER", holder, ^parent, {:checkin, ref, _extra}} ->
Process.unlink(parent)
pool_ref = DBConnection.Holder.pool_ref(pool: parent, reference: ref, holder: holder)
checkout = {:via, __MODULE__, pool_ref}
_ = DBConnection.run(checkout, make_fun(fun), [pool: __MODULE__] ++ opts)
exit(:normal)
end
end
def checkout({:via, __MODULE__, pool_ref}, _callers, _opts) do
{:ok, pool_ref, _mod = :unused, _idle_time = nil, _state = :unused}
end
defp make_fun(fun) when is_function(fun, 1) do
fun
end
defp make_fun(mfargs) do
fn conn ->
{mod, fun, args} = mfargs
apply(mod, fun, [conn | args])
end
end
end

View File

@@ -0,0 +1,116 @@
defmodule DBConnection.TelemetryListener do
@moduledoc """
A connection listener that emits telemetry events for connection and disconnection
It monitors connection processes and ensures that disconnection events are
always emitted.
## Usage
Start the listener, and pass it under the `:connection_listeners` option when
starting DBConnection:
{:ok, pid} = DBConnection.TelemetryListener.start_link()
{:ok, _conn} = DBConnection.start_link(SomeModule, connection_listeners: [pid])
# Using a tag, which will be sent in telemetry metadata
{:ok, _conn} = DBConnection.start_link(SomeModule, connection_listeners: {[pid], :my_tag})
# Or, with a Supervisor:
Supervisor.start_link([
{DBConnection.TelemetryListener, name: MyListener},
DBConnection.child_spec(SomeModule, connection_listeners: {[MyListener], :my_tag})
])
When using with Ecto, you can pass the `connection_listeners` option to Ecto, and we
recommend passing the repository as the tag. In your supervision tree:
Supervisor.start_link([
{DBConnection.TelemetryListener, name: MyApp.DBListener},
{MyApp.Repo, connection_listeners: {[MyApp.DBListener], MyApp.Repo})
])
## Telemetry events
### Connected
`[:db_connection, :connected]` - Executed after a connection is established.
#### Measurements
* `:count` - Always 1
#### Metadata
* `:pid` - The connection pid
* `:tag` - The connection pool tag
### Disconnected
`[:db_connection, :disconnected]` - Executed after a disconnect.
#### Measurements
* `:count` - Always 1
#### Metadata
* `:pid` - The connection pid
* `:tag` - The connection pool tag
"""
use GenServer
@doc "Starts a telemetry listener"
@spec start_link(GenServer.options()) :: {:ok, pid()}
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, nil, opts)
end
@impl GenServer
def init(nil) do
{:ok, %{monitoring: %{}}}
end
@impl GenServer
def handle_info({:connected, pid, tag}, state) do
handle_connected(pid, tag, state)
end
def handle_info({:connected, pid}, state) do
handle_connected(pid, nil, state)
end
def handle_info({:disconnected, pid, _}, state) do
handle_disconnected(pid, state)
end
def handle_info({:disconnected, pid}, state) do
handle_disconnected(pid, state)
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
handle_disconnected(pid, state)
end
defp handle_connected(pid, tag, state) do
:telemetry.execute([:db_connection, :connected], %{count: 1}, %{tag: tag, pid: pid})
ref = Process.monitor(pid)
{:noreply, put_in(state.monitoring[pid], {ref, tag})}
end
defp handle_disconnected(pid, state) do
case state.monitoring[pid] do
# Already handled. We may receive two messages: one from monitor and one
# from listener. For this reason, we need to handle both.
nil ->
{:noreply, state}
{ref, tag} ->
Process.demonitor(ref, [:flush])
:telemetry.execute([:db_connection, :disconnected], %{count: 1}, %{tag: tag, pid: pid})
{:noreply, %{state | monitoring: Map.delete(state.monitoring, pid)}}
end
end
end

View File

@@ -0,0 +1,62 @@
defmodule DBConnection.Util do
@moduledoc false
@doc """
Inspect a pid, including the process label if possible.
"""
def inspect_pid(pid) when is_pid(pid) do
with :undefined <- get_label(pid),
:undefined <- get_name(pid),
:undefined <- get_initial_call(pid) do
inspect(pid)
else
label_or_name_or_call -> "#{inspect(pid)} (#{inspect(label_or_name_or_call)})"
end
end
def inspect_pid(other), do: inspect(other)
defp get_name(pid) do
try do
Process.info(pid, :registered_name)
rescue
_ -> :undefined
else
{:registered_name, name} when is_atom(name) -> name
_ -> :undefined
end
end
@doc """
Set a process label if `Process.set_label/1` is available.
"""
def set_label(label) do
if function_exported?(Process, :set_label, 1) do
Process.set_label(label)
else
:ok
end
end
# Get a process label if `:proc_lib.get_label/1` is available.
defp get_label(pid) do
if function_exported?(:proc_lib, :get_label, 1) do
# Avoid a compiler warning if the function isn't
# defined in your version of Erlang/OTP
apply(:proc_lib, :get_label, [pid])
else
# mimic return value of
# `:proc_lib.get_label/1` when none is set.
# Don't resort to using `Process.info(pid, :dictionary)`,
# as this is not efficient.
:undefined
end
end
defp get_initial_call(pid) do
case Process.info(pid, :initial_call) do
{:initial_call, {mod, _, _}} -> mod
_ -> :undefined
end
end
end

View File

@@ -0,0 +1,65 @@
defmodule DBConnection.Watcher do
@moduledoc false
@name __MODULE__
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: @name)
end
def watch(supervisor, args) do
GenServer.call(@name, {:watch, supervisor, args}, :infinity)
end
@impl true
def init(:ok) do
Process.flag(:trap_exit, true)
{:ok, {%{}, %{}}}
end
@impl true
def handle_call({:watch, supervisor, args}, {caller_pid, _ref}, {caller_refs, started_refs}) do
case DynamicSupervisor.start_child(supervisor, args) do
{:ok, started_pid} ->
Process.link(caller_pid)
caller_ref = Process.monitor(caller_pid)
started_ref = Process.monitor(started_pid)
caller_refs = Map.put(caller_refs, caller_ref, {supervisor, started_pid, started_ref})
started_refs = Map.put(started_refs, started_ref, {caller_pid, caller_ref})
{:reply, {:ok, started_pid}, {caller_refs, started_refs}}
other ->
{:reply, other, {caller_refs, started_refs}}
end
end
@impl true
def handle_info({:DOWN, ref, _, _, _}, {caller_refs, started_refs}) do
case caller_refs do
%{^ref => {supervisor, started_pid, started_ref}} ->
Process.demonitor(started_ref, [:flush])
DynamicSupervisor.terminate_child(supervisor, started_pid)
{:noreply, {Map.delete(caller_refs, ref), Map.delete(started_refs, started_ref)}}
%{} ->
%{^ref => {caller_pid, caller_ref}} = started_refs
Process.demonitor(caller_ref, [:flush])
Process.exit(caller_pid, :kill)
{:noreply, {Map.delete(caller_refs, caller_ref), Map.delete(started_refs, ref)}}
end
end
def handle_info({:EXIT, _, _}, state) do
{:noreply, state}
end
@impl true
def terminate(_, {_, started_refs}) do
for {_, {caller_pid, _}} <- started_refs do
Process.exit(caller_pid, :kill)
end
:ok
end
end

View File

@@ -0,0 +1,84 @@
defmodule DBConnection.Mixfile do
use Mix.Project
@source_url "https://github.com/elixir-ecto/db_connection"
@pools [:connection_pool, :ownership]
@version "2.9.0"
def project do
[
app: :db_connection,
version: @version,
elixir: "~> 1.11",
deps: deps(),
docs: docs(),
description: description(),
package: package(),
build_per_environment: false,
consolidate_protocols: false,
test_paths: test_paths(Mix.env()),
aliases: ["test.all": ["test", "test.pools"], "test.pools": &test_pools/1],
preferred_cli_env: ["test.all": :test]
]
end
def application do
[
extra_applications: [:logger],
mod: {DBConnection.App, []}
]
end
defp deps do
[
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:telemetry, "~> 0.4 or ~> 1.0"}
]
end
defp docs do
[
source_url: @source_url,
source_ref: "v#{@version}",
main: DBConnection,
extras: ["CHANGELOG.md"]
]
end
defp description do
"""
Database connection behaviour for database transactions and connection pooling
"""
end
defp package do
%{
licenses: ["Apache-2.0"],
maintainers: ["James Fish", "José Valim"],
links: %{"GitHub" => @source_url}
}
end
defp test_paths(pool) when pool in @pools, do: ["integration_test/#{pool}"]
defp test_paths(_), do: ["test"]
defp test_pools(args) do
for env <- @pools, do: env_run(env, args)
end
defp env_run(env, args) do
args = if IO.ANSI.enabled?(), do: ["--color" | args], else: ["--no-color" | args]
IO.puts("==> Running tests for MIX_ENV=#{env} mix test")
{_, res} =
System.cmd("mix", ["test" | args],
into: IO.binstream(:stdio, :line),
env: [{"MIX_ENV", to_string(env)}]
)
if res > 0 do
System.at_exit(fn _ -> exit({:shutdown, 1}) end)
end
end
end

View File

@@ -0,0 +1,3 @@
[
inputs: ["{bench,mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

BIN
phoenix/deps/decimal/.hex Normal file

Binary file not shown.

View File

@@ -0,0 +1,150 @@
# CHANGELOG
## v2.3.0 (2024-12-13)
* Implement the upcoming [`JSON.Encoder`](https://hexdocs.pm/elixir/main/JSON.Encoder.html)
protocol
## v2.2.0 (2024-11-13)
* Add `Decimal.gte?/2` and `Decimal.lte?/2`
* Add `Decimal.compare/3` and `Decimal.eq?/3` with threshold as parameter
## v2.1.1 (2023-04-26)
Decimal v2.1 requires Elixir v1.8+.
### Bug fixes
* Fix `Decimal.compare/2` when comparing against `0`
## v2.1.0 (2023-04-26)
Decimal v2.1 requires Elixir v1.8+.
### Enhancements
* Improve error message from `Decimal.to_integer/1` during precision loss
* `Inspect` protocol implementation returns strings in the `Decimal.new(...)` format
* Add `Decimal.scale/1`
* Optimize `Decimal.compare/2` for numbers with large exponents
### Bug fixes
* Fix `Decimal.integer?/1` spec
* Fix `Decimal.integer?/1` check on 0 with >1 significant digits
## v2.0.0 (2020-09-08)
Decimal v2.0 requires Elixir v1.2+.
### Enhancements
* Add `Decimal.integer?/1`
### Breaking changes
* Change `Decimal.compare/2` to return `:lt | :eq | :gt`
* Change `Decimal.cast/1` to return `{:ok, t} | :error`
* Change `Decimal.parse/1` to return `{t, binary} | :error`
* Remove `:message` and `:result` fields from `Decimal.Error`
* Remove sNaN
* Rename qNaN to NaN
* Remove deprecated support for floats in `Decimal.new/1`
* Remove deprecated `Decimal.minus/1`
* Remove deprecated `Decimal.plus/1`
* Remove deprecated `Decimal.reduce/1`
* Remove deprecated `Decimal.with_context/2`, `Decimal.get_context/1`, `Decimal.set_context/1`,
and `Decimal.update_context/1`
* Remove deprecated `Decimal.decimal?/1`
### Deprecations
* Deprecate `Decimal.cmp/2`
## v1.9.0 (2020-09-08)
### Enhancements
* Add `Decimal.negate/1`
* Add `Decimal.apply_context/1`
* Add `Decimal.normalize/1`
* Add `Decimal.Context.with/2`, `Decimal.Context.get/1`, `Decimal.Context.set/2`,
and `Decimal.Context.update/1`
* Add `Decimal.is_decimal/1`
### Deprecations
* Deprecate `Decimal.minus/1` in favour of the new `Decimal.negate/1`
* Deprecate `Decimal.plus/1` in favour of the new `Decimal.apply_context/1`
* Deprecate `Decimal.reduce/1` in favour of the new `Decimal.normalize/1`
* Deprecate `Decimal.with_context/2`, `Decimal.get_context/1`, `Decimal.set_context/2`,
and `Decimal.update_context/1` in favour of new functions on the `Decimal.Context` module
* Deprecate `Decimal.decimal?/1` in favour of the new `Decimal.is_decimal/1`
## v1.8.1 (2019-12-20)
### Bug fixes
* Fix Decimal.compare/2 with string arguments
* Set :signal on error
## v1.8.0 (2019-06-24)
### Enhancements
* Add `Decimal.cast/1`
* Add `Decimal.eq?/2`, `Decimal.gt?/2`, and `Decimal.lt?/2`
* Add guards to `Decimal.new/3` to prevent invalid Decimal numbers
## v1.7.0 (2019-02-16)
### Enhancements
* Add `Decimal.sqrt/1`
## v1.6.0 (2018-11-22)
### Enhancements
* Support for canonical XSD representation on `Decimal.to_string/2`
### Bugfixes
* Fix exponent off-by-one when converting from decimal to float
* Fix negative?/1 and positive?/1 specs
### Deprecations
* Deprecate passing float to `Decimal.new/1` in favor of `Decimal.from_float/1`
## v1.5.0 (2018-03-24)
### Enhancements
* Add `Decimal.positive?/1` and `Decimal.negative?/1`
* Accept integers and strings in arithmetic functions, e.g.: `Decimal.add(1, "2.0")`
* Add `Decimal.from_float/1`
### Soft deprecations (no warnings emitted)
* Soft deprecate passing float to `new/1` in favor of `from_float/1`
## v1.4.1 (2017-10-12)
### Bugfixes
* Include the given value as part of the error reason
* Fix `:half_even` `:lists.last` bug (empty signif)
* Fix error message for round
* Fix `:half_down` rounding error when remainder is greater than 5
* Fix `Decimal.new/1` float conversion with bigger precision than 4
* Fix precision default value
## v1.4.0 (2017-06-25)
### Bugfixes
* Fix `Decimal.to_integer/1` for large coefficients
* Fix rounding of ~0 values
* Fix errors when comparing and adding two infinities

Some files were not shown because too many files have changed in this diff Show More