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

Binary file not shown.

View File

@@ -0,0 +1,328 @@
## 1.4.3 (12 Dec 2025)
### Enhancements
* Add support for setting process labels for better observer visibility (#188, thanks @NelsonVides and @nathanl!)
* Improve memory usage and performance (#181 & #182, thanks @NelsonVides!)
## 1.4.2 (19 Oct 2025)
### Changes
* Resolve compiler warnings in Elixir 1.19 (#171, thanks @psantos10!)
## 1.4.1 (4 Sept 2025)
### Enhancements
* Improve performance of SSL sendfile function on larger files (#176, thanks @NelsonVides!)
## 1.4.0 (22 Aug 2025)
### Enhancements
* Add `num_listen_sockets` parameter to allow for improved performance on
specific systems (#174, thanks @NelsonVides!)
## 1.3.14 (25 May 2025)
### Enhancements
* Add support for `ThousandIsland.Socket.connection_information/1` to get info
about the underlying SSL connection, if one is present.
## 1.3.13 (29 Apr 2025)
### Enhancements
* Allow `handle_connection` and `handle_data` callbacks to return tuples to call
`handle_continue` per GenServer conventions (#166)
## 1.3.12 (21 Mar 2025)
### Fixes
* Do not kill off acceptors when encountering an `:einval` socket status (#162,
thanks @marschro!)
## 1.3.11 (23 Feb 2025)
### Fixes
* Properly support `inet_backend` transport option for TCP connections (#155, thanks @paulswartz!)
## 1.3.10 (17 Feb 2025)
### Enhancements
* Add support for `certs_keys` TLS config option (#153, thanks @joshk!)
## 1.3.9 (6 Jan 2025)
### Fixes
* Explicitly ignore several Handler return values to silence Dialyzer in dependent libraries
## 1.3.8 (5 Jan 2025)
### Changes
* Refactor `ThousandIsland.Handler` implementation to facilitate partial reuse
by custom Handler authors (#146 thanks @mruoss!)
## 1.3.7 (29 Nov 2024)
### Fixes
* Use socket as returned from transport handshake (#142, thanks @danielspofford!)
## 1.3.6 (18 Nov 2024)
### Fixes
* Don't consider remote end closure as an error condition for telemetry
* Fix typo & clarify docs (#117)
* Update security policy (#138)
## 1.3.5 (24 Feb 2024)
### Fixes
* Fix regression on non-keyword options (such as `:inet6`) introduced in 1.3.4
(#113, thanks @danschultzer!)
## 1.3.4 (23 Feb 2024)
### Fixes
* Fix the ability to override default options (and not override hardcoded
options) for both TCP and SSL transports (#111, thanks @moogle19!)
## 1.3.3 (21 Feb 2024)
### Changes
* Do not set `:linger` as a default option on either TCP or SSL transports. This
was causing oddball node hangs on larger instances (#110, thanks @jonjon &
@pojiro!)
## 1.3.2 (31 Dec 2023)
### Changes
* Allow `sni_hosts` or `sni_fun` in place of `cert`/`certfile` and
`key`/`keyfile`
### Fixes
* Move `handle_info` fallback clause to module compilation hook (#105)
## 1.3.1 (30 Dec 2023)
### Fixes
* Fix downstream dialyzer error introduced by #86
## 1.3.0 (30 Dec 2023)
### Fixes
* Fix issue with eventual acceptor starvation when handling certain network
conditions (#103)
### Enhancements
* Add check (and logging) for cases where a Handler implementation does not
return the expected state from GenServer handle_* calls (#96, thanks
@elfenlaid!)
* Allow protocol upgrades within an active connection (#86, thanks @icehaunter!)
### Changes
* Improve file list for hex packaging (#98, thanks @patrickjaberg and
@wojtekmach!)
## 1.2.0 (11 Nov 2023)
### Enhancements
* Add `supervisor_options` option to specify top-level supervisor options (#97)
## 1.1.0 (23 Oct 2023)
### Enhancements
* Add support for `silent_terminate_on_error` option to run quietly in case of
error (#92)
## 1.0.0 (18 Oct 2023)
### Changes
* Do not consider `ECONNABORTED` errors to be exceptional at socket start time
(#72, thanks @dch!)
## 1.0.0-pre.7 (12 Aug 2023)
### Fixes
* Fix file handle leak when calling `c:Socket.sendfile` (#78)
## 1.0.0-pre.6 (28 Jun 2023)
### Enhancements
* Add support for suspending the acceptance of new requests while continuing to
process existing ones (#70)
### Changes
* Drop Elixir 1.12 as a supported target (it should continue to work, but is no
longer covered by CI)
## 1.0.0-pre.5 (16 Jun 2023)
### Changes
* **BREAKING CHANGE** Refactor socket informational functions to mirror the
underlying `:gen_tcp` and `:ssl` APIs (#76, thanks @asakura!). Details:
* Add `ThousandIsland.Socket.sockname/1`
* Add `ThousandIsland.Socket.peername/1`
* Add `ThousandIsland.Socket.peercert/1`
* Remove `ThousandIsland.Socket.local_info/1`
* Remove `ThousandIsland.Socket.peer_info/1`
* Refactor telemetry (#65 thanks @asakura!)
## 1.0.0-pre.4 (13 Jun 2023)
### Enhancements
* Improve typespecs (thanks @asakura!)
* Clean up docs & various internal cleanups (thanks @asakura!)
## 1.0.0-pre.3 (2 Jun 2023)
### Enhancements
* Total overhaul of typespecs throughout the library (thanks @asakura!)
* Internal refactor of several ancillary functions (thanks @asakura!)
## 1.0.0-pre.2 (3 May 2023)
### Enhancements
* Doc improvements
## 1.0.0-pre.1 (19 Apr 2023)
### Changes
* None
## 0.6.7 (9 Apr 2023)
### Changes
* Thousand Island now sets its `id` field in its spec to be `{ThousandIsland, ref()}`
* `num_connections` defaults to 16384 connections per acceptor
### Enhancements
* Doc improvements
## 0.6.6 (7 Apr 2023)
### Enhancements
* Added `num_connections` parameter to specify the max number of concurrent
connections allowed per acceptor
* Added `max_connections_retry_count` and `max_connections_retry_wait`
parameters to configure how Thousand Island behaves when max connections are
reached
* Added `[:thousand_island, :acceptor, :spawn_error]` telemetry event to track
when max connections are reached
* Added max connection logging as part of the
`ThousandIsland.Logger.attach_logger(:error)` level
### Changes
* Refactored connection startup logic to move some burden from acceptor to
connection process
## 0.6.5 (27 Mar 2023)
### Changes
* Handshake errors no longer loudly crash the handler process
* `handle_error/3` documentation updated to explicitly mention handshake errors
## 0.6.4 (17 Mar 2023)
### Changes
* Modified telemetry event payloads to match the conventions espoused by
`:telemetry.span/3`
## 0.6.3 (14 Mar 2023)
### Enhancements
* Added `shutdown_timeout` configuration parameter to configure how long to wait
for existing connections to shutdown before forcibly killing them at shutdown
### Changes
* Default value for `num_acceptors` is now 100
* Default value for `read_timeout` is now 60000 ms
### Fixes
* Added missing documentation for read_timeout configuration parameter
## 0.6.2 (22 Feb 2023)
### Fixes
* Fixes a race condition at application shutdown that caused spurious acceptor
crashes to litter the logs (#44). Thanks @danschultzer!
## 0.6.1 (19 Feb 2023)
### Changes
* Expose ThousandIsland.Telemetry.t() as a transparent type
* Pass telemetry errors in metadata rather than metrics
* Allow explicit passing of start times into telemetry spans
## 0.6.0 (4 Feb 2023)
### Enhancements
* (Re)introduce telemetry support as specified in the `ThousandIsland.Telemetry`
module
# Changelog for 0.5.x
## 0.5.17 (31 Jan 2023)
### Enhancements
* Add `ThousandIsland.connection_pids/1` to enumerate connection processes
## 0.5.16 (21 Jan 2023)
### Enhancements
* Narrow internal pattern matches to enable Handlers to use their own socket calls (#39)
### Fixes
* Fix documentation typos
## 0.5.15 (10 Jan 2023)
### Enhancements
* Do not emit GenServer crash logs in connection timeout situations
* Doc updates

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 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,166 @@
![Thousand Island](https://github.com/mtrudel/thousand_island/raw/main/assets/readme_logo.png#gh-light-mode-only)
![Thousand Island](https://github.com/mtrudel/thousand_island/raw/main/assets/readme_logo-white.png#gh-dark-mode-only)
[![Build Status](https://github.com/mtrudel/thousand_island/workflows/Elixir%20CI/badge.svg)](https://github.com/mtrudel/thousand_island/actions)
[![Docs](https://img.shields.io/badge/api-docs-green.svg?style=flat)](https://hexdocs.pm/thousand_island)
[![Hex.pm](https://img.shields.io/hexpm/v/thousand_island.svg?style=flat&color=blue)](https://hex.pm/packages/thousand_island)
Thousand Island is a modern, pure Elixir socket server, inspired heavily by
[ranch](https://github.com/ninenines/ranch). It aims to be easy to understand
and reason about, while also being at least as stable and performant as alternatives.
Informal tests place ranch and Thousand Island at roughly the same level of
performance and overhead; short of synthetic scenarios on the busiest of servers,
they perform equally for all intents and purposes.
Thousand Island is written entirely in Elixir, and is nearly dependency-free (the
only library used is [telemetry](https://github.com/beam-telemetry/telemetry)).
The application strongly embraces OTP design principles, and emphasizes readable,
simple code. The hope is that as much as Thousand Island is capable of backing
the most demanding of services, it is also useful as a simple and approachable
reference for idiomatic OTP design patterns.
## Usage
Thousand Island is implemented as a supervision tree which is intended to be hosted
inside a host application, often as a dependency embedded within a higher-level
protocol library such as [Bandit](https://github.com/mtrudel/bandit). Aside from
supervising the Thousand Island process tree, applications interact with Thousand
Island primarily via the
[`ThousandIsland.Handler`](https://hexdocs.pm/thousand_island/ThousandIsland.Handler.html) behaviour.
### Handlers
The [`ThousandIsland.Handler`](https://hexdocs.pm/thousand_island/ThousandIsland.Handler.html) behaviour defines the interface that Thousand Island
uses to pass [`ThousandIsland.Socket`](https://hexdocs.pm/thousand_island/ThousandIsland.Socket.html)s up to the application level; together they
form the primary interface that most applications will have with Thousand Island.
Thousand Island comes with a few simple protocol handlers to serve as examples;
these can be found in the [examples](https://github.com/mtrudel/thousand_island/tree/main/examples)
folder of this project. A simple implementation would look like this:
```elixir
defmodule Echo do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
ThousandIsland.Socket.send(socket, data)
{:continue, state}
end
end
{:ok, pid} = ThousandIsland.start_link(port: 1234, handler_module: Echo)
```
For more information, please consult the [`ThousandIsland.Handler`](https://hexdocs.pm/thousand_island/ThousandIsland.Handler.html) documentation.
### Starting a Thousand Island Server
Thousand Island servers exist as a supervision tree, and are started by a call
to
[`ThousandIsland.start_link/1`](https://hexdocs.pm/thousand_island/ThousandIsland.html#start_link/1).
There are a number of options supported; for a complete description, consult the
[Thousand Island
docs](https://hexdocs.pm/thousand_island/ThousandIsland.html#t:options/0).
### Connection Draining & Shutdown
The `ThousandIsland.Server` process is just a standard `Supervisor`, so all the
usual rules regarding shutdown and shutdown timeouts apply. Immediately upon
beginning the shutdown sequence the `ThousandIsland.ShutdownListener` will cause
the listening socket to shut down, which in turn will cause all of the `Acceptor`
processes to shut down as well. At this point all that is left in the supervision
tree are several layers of Supervisors and whatever `Handler` processes were
in progress when shutdown was initiated. At this point, standard Supervisor shutdown
timeout semantics give existing connections a chance to finish things up. `Handler`
processes trap exit, so they continue running beyond shutdown until they either
complete or are `:brutal_kill`ed after their shutdown timeout expires.
The `shutdown_timeout` configuration option allows for fine grained control of
the shutdown timeout value. It defaults to 15000 ms.
### Logging & Telemetry
As a low-level library, Thousand Island purposely does not do any inline
logging of any kind. The [`ThousandIsland.Logger`](https://hexdocs.pm/thousand_island/ThousandIsland.Logger.html) module defines a number of
functions to aid in tracing connections at various log levels, and such logging
can be dynamically enabled and disabled against an already running server. This
logging is backed by telemetry events internally.
Thousand Island emits a rich set of telemetry events including spans for each
server, acceptor process, and individual client connection. These telemetry
events are documented in the [`ThousandIsland.Telemetry`](https://hexdocs.pm/thousand_island/ThousandIsland.Telemetry.html) module.
## Implementation Notes
At a top-level, a `Server` coordinates the processes involved in responding to
connections on a socket. A `Server` manages two top-level processes: a `Listener`
which is responsible for actually binding to the port and managing the resultant
listener socket, and an `AcceptorPoolSupervisor` which is responsible for managing
a pool of `AcceptorSupervisor` processes.
Each `AcceptorSupervisor` process (there are 100 by default) manages two processes:
an `Acceptor` which accepts connections made to the server's listener socket,
and a `DynamicSupervisor` which supervises the processes backing individual
client connections. Every time a client connects to the server's port, one of
the `Acceptor`s receives the connection in the form of a socket. It then creates
a new process based on the configured handler to manage this connection, and
immediately waits for another connection. It is worth noting that `Acceptor`
processes are long-lived, and normally live for the entire period that the
`Server` is running.
A handler process is tied to the lifecycle of a client connection, and is
only started when a client connects. The length of its lifetime beyond that of the
underlying connection is dependent on the behaviour of the configured Handler module.
In typical cases its lifetime is directly related to that of the underlying connection.
This hierarchical approach reduces the time connections spend waiting to be accepted,
and also reduces contention for `DynamicSupervisor` access when creating new
`Handler` processes. Each `AcceptorSupervisor` subtree functions nearly
autonomously, improving scalability and crash resiliency.
Graphically, this shakes out like so:
```mermaid
graph TD;
Server(Server: supervisor, rest_for_one)-->Listener;
Server-->AcceptorPoolSupervisor(AcceptorPoolSupervisor: dynamic supervisor);
AcceptorPoolSupervisor--1...n-->AcceptorSupervisor(AcceptorSupervisor: supervisor, rest_for_one)
AcceptorSupervisor-->DynamicSupervisor
AcceptorSupervisor-->Acceptor(Acceptor: task)
DynamicSupervisor--1...n-->Handler(Handler: gen_server)
Server-->ShutdownListener;
```
Thousand Island does not use named processes or other 'global' state internally
(other than telemetry event names). It is completely supported for a single node
to host any number of `Server` processes each listening on a different port.
## Contributing
Contributions to Thousand Island 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/thousand_island/blob/main/CODE_OF_CONDUCT.md).
Security disclosures should be handled per Thousand Island's published [security policy](https://github.com/mtrudel/thousand_island/blob/main/SECURITY.md).
## Installation
Thousand Island is [available in Hex](https://hex.pm/packages/thousand_island). The package
can be installed by adding `thousand_island` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:thousand_island, "~> 1.0"}
]
end
```
Documentation can be found at [https://hexdocs.pm/thousand_island](https://hexdocs.pm/thousand_island).
## License
MIT

View File

@@ -0,0 +1,35 @@
{<<"links">>,
[{<<"Changelog">>,<<"https://hexdocs.pm/thousand_island/changelog.html">>},
{<<"GitHub">>,<<"https://github.com/mtrudel/thousand_island">>}]}.
{<<"name">>,<<"thousand_island">>}.
{<<"version">>,<<"1.4.3">>}.
{<<"description">>,<<"A simple & modern pure Elixir socket server">>}.
{<<"elixir">>,<<"~> 1.13">>}.
{<<"app">>,<<"thousand_island">>}.
{<<"licenses">>,[<<"MIT">>]}.
{<<"files">>,
[<<"lib">>,<<"lib/thousand_island.ex">>,<<"lib/thousand_island">>,
<<"lib/thousand_island/telemetry.ex">>,
<<"lib/thousand_island/server_config.ex">>,
<<"lib/thousand_island/logger.ex">>,
<<"lib/thousand_island/shutdown_listener.ex">>,
<<"lib/thousand_island/transports">>,
<<"lib/thousand_island/transports/ssl.ex">>,
<<"lib/thousand_island/transports/tcp.ex">>,
<<"lib/thousand_island/server.ex">>,<<"lib/thousand_island/handler.ex">>,
<<"lib/thousand_island/acceptor_pool_supervisor.ex">>,
<<"lib/thousand_island/acceptor.ex">>,
<<"lib/thousand_island/transport.ex">>,<<"lib/thousand_island/socket.ex">>,
<<"lib/thousand_island/handler_config.ex">>,
<<"lib/thousand_island/listener.ex">>,
<<"lib/thousand_island/process_label.ex">>,
<<"lib/thousand_island/connection.ex">>,
<<"lib/thousand_island/acceptor_supervisor.ex">>,<<"mix.exs">>,
<<"README.md">>,<<"LICENSE">>,<<"CHANGELOG.md">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"telemetry">>},
{<<"app">>,<<"telemetry">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 0.4 or ~> 1.0">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,286 @@
defmodule ThousandIsland do
@moduledoc """
Thousand Island is a modern, pure Elixir socket server, inspired heavily by
[ranch](https://github.com/ninenines/ranch). It aims to be easy to understand
& reason about, while also being at least as stable and performant as alternatives.
Thousand Island is implemented as a supervision tree which is intended to be hosted
inside a host application, often as a dependency embedded within a higher-level
protocol library such as [Bandit](https://github.com/mtrudel/bandit). Aside from
supervising the Thousand Island process tree, applications interact with Thousand
Island primarily via the `ThousandIsland.Handler` behaviour.
## Handlers
The `ThousandIsland.Handler` behaviour defines the interface that Thousand Island
uses to pass `ThousandIsland.Socket`s up to the application level; together they
form the primary interface that most applications will have with Thousand Island.
Thousand Island comes with a few simple protocol handlers to serve as examples;
these can be found in the [examples](https://github.com/mtrudel/thousand_island/tree/main/examples)
folder of this project. A simple implementation would look like this:
```elixir
defmodule Echo do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
ThousandIsland.Socket.send(socket, data)
{:continue, state}
end
end
{:ok, pid} = ThousandIsland.start_link(port: 1234, handler_module: Echo)
```
For more information, please consult the `ThousandIsland.Handler` documentation.
## Starting a Thousand Island Server
A typical use of `ThousandIsland` might look like the following:
```elixir
defmodule MyApp.Supervisor do
# ... other Supervisor boilerplate
def init(config) do
children = [
# ... other children as dictated by your app
{ThousandIsland, port: 1234, handler_module: MyApp.ConnectionHandler}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
```
You can also start servers directly via the `start_link/1` function:
```elixir
{:ok, pid} = ThousandIsland.start_link(port: 1234, handler_module: MyApp.ConnectionHandler)
```
## Configuration
A number of options are defined when starting a server. The complete list is
defined by the `t:ThousandIsland.options/0` type.
## Connection Draining & Shutdown
`ThousandIsland` instances are just a process tree consisting of standard
`Supervisor`, `GenServer` and `Task` modules, and so the usual rules regarding
shutdown and shutdown timeouts apply. Immediately upon beginning the shutdown
sequence the ThousandIsland.ShutdownListener process will cause the listening
socket to shut down, which in turn will cause all of the
ThousandIsland.Acceptor processes to shut down as well. At this point all that
is left in the supervision tree are several layers of Supervisors and whatever
`Handler` processes were in progress when shutdown was initiated. At this
point, standard `Supervisor` shutdown timeout semantics give existing
connections a chance to finish things up. `Handler` processes trap exit, so
they continue running beyond shutdown until they either complete or are
`:brutal_kill`ed after their shutdown timeout expires.
## Logging & Telemetry
As a low-level library, Thousand Island purposely does not do any inline
logging of any kind. The `ThousandIsland.Logger` module defines a number of
functions to aid in tracing connections at various log levels, and such logging
can be dynamically enabled and disabled against an already running server. This
logging is backed by telemetry events internally.
Thousand Island emits a rich set of telemetry events including spans for each
server, acceptor process, and individual client connection. These telemetry
events are documented in the `ThousandIsland.Telemetry` module.
"""
@typedoc """
Possible options to configure a server. Valid option values are as follows:
* `handler_module`: The name of the module used to handle connections to this server.
The module is expected to implement the `ThousandIsland.Handler` behaviour. Required
* `handler_options`: A term which is passed as the initial state value to
`c:ThousandIsland.Handler.handle_connection/2` calls. Optional, defaulting to nil
* `port`: The TCP port number to listen on. If not specified this defaults to 4000.
If a port number of `0` is given, the server will dynamically assign a port number
which can then be obtained via `ThousandIsland.listener_info/1` or
`ThousandIsland.Socket.sockname/1`
* `transport_module`: The name of the module which provides basic socket functions.
Thousand Island provides `ThousandIsland.Transports.TCP` and `ThousandIsland.Transports.SSL`,
which provide clear and TLS encrypted TCP sockets respectively. If not specified this
defaults to `ThousandIsland.Transports.TCP`
* `transport_options`: A keyword list of options to be passed to the transport module's
`c:ThousandIsland.Transport.listen/2` function. Valid values depend on the transport
module specified in `transport_module` and can be found in the documentation for the
`ThousandIsland.Transports.TCP` and `ThousandIsland.Transports.SSL` modules. Any options
in terms of interfaces to listen to / certificates and keys to use for SSL connections
will be passed in via this option
* `genserver_options`: A term which is passed as the option value to the handler module's
underlying `GenServer.start_link/3` call. Optional, defaulting to `[]`
* `supervisor_options`: A term which is passed as the option value to this server's top-level
supervisor's `Supervisor.start_link/3` call. Useful for setting the `name` for this server.
Optional, defaulting to `[]`
* `num_acceptors`: The number of acceptor processes to run. Defaults to 100
* `num_listen_sockets`: The number of listener sockets to create. When set to a value greater
than 1, multiple listener sockets will be created to distribute incoming connections across
multiple sockets for improved performance on multi-core systems. This requires setting either
`reuseport: true` or `reuseport_lb: true` in the `transport_options`, and will only work on
systems that support such socket functionality (most modern Unix-like systems). If the system
does not support the required socket options, server startup will fail. This value must be
less than or equal to `num_acceptors`. Defaults to 1
* `num_connections`: The maximum number of concurrent connections which each acceptor will
accept before throttling connections. Connections will be throttled by having the acceptor
process wait `max_connections_retry_wait` milliseconds, up to `max_connections_retry_count`
times for existing connections to terminate & make room for this new connection. If there is
still no room for this new connection after this interval, the acceptor will close the client
connection and emit a `[:thousand_island, :acceptor, :spawn_error]` telemetry event. This number
is expressed per-acceptor, so the total number of maximum connections for a Thousand Island
server is `num_acceptors * num_connections`. Defaults to `16_384`
* `max_connections_retry_wait`: How long to wait during each iteration as described in
`num_connectors` above, in milliseconds. Defaults to `1000`
* `max_connections_retry_count`: How many iterations to wait as described in `num_connectors`
above. Defaults to `5`
* `read_timeout`: How long to wait for client data before closing the connection, in
milliseconds. Defaults to 60_000
* `shutdown_timeout`: How long to wait for existing client connections to complete before
forcibly shutting those connections down at server shutdown time, in milliseconds. Defaults to
15_000. May also be `:infinity` or `:brutal_kill` as described in the `Supervisor`
documentation
* `silent_terminate_on_error`: Whether to silently ignore errors returned by the handler or to
surface them to the runtime via an abnormal termination result. This only applies to errors
returned via `{:error, reason, state}` responses; exceptions raised within a handler are always
logged regardless of this value. Note also that telemetry events will always be sent for errors
regardless of this value. Defaults to false
"""
@type options :: [
handler_module: module(),
handler_options: term(),
genserver_options: GenServer.options(),
supervisor_options: [Supervisor.option()],
port: :inet.port_number(),
transport_module: module(),
transport_options: transport_options(),
num_acceptors: pos_integer(),
num_listen_sockets: pos_integer(),
num_connections: non_neg_integer() | :infinity,
max_connections_retry_count: non_neg_integer(),
max_connections_retry_wait: timeout(),
read_timeout: timeout(),
shutdown_timeout: timeout(),
silent_terminate_on_error: boolean()
]
@typedoc "A module implementing `ThousandIsland.Transport` behaviour"
@type transport_module :: ThousandIsland.Transports.TCP | ThousandIsland.Transports.SSL
@typedoc "A keyword list of options to be passed to the transport module's `listen/2` function"
@type transport_options() ::
ThousandIsland.Transports.TCP.options() | ThousandIsland.Transports.SSL.options()
@doc false
@spec child_spec(options()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: {__MODULE__, make_ref()},
start: {__MODULE__, :start_link, [opts]},
type: :supervisor,
restart: :permanent
}
end
@doc """
Starts a `ThousandIsland` instance with the given options. Returns a pid
that can be used to further manipulate the server via other functions defined on
this module in the case of success, or an error tuple describing the reason the
server was unable to start in the case of failure.
"""
@spec start_link(options()) :: Supervisor.on_start()
def start_link(opts \\ []) do
opts
|> ThousandIsland.ServerConfig.new()
|> ThousandIsland.Server.start_link()
end
@doc """
Returns information about the address and port that the server is listening on
"""
@spec listener_info(Supervisor.supervisor()) ::
{:ok, ThousandIsland.Transport.socket_info()} | :error
def listener_info(supervisor) do
case ThousandIsland.Server.listener_pid(supervisor) do
nil -> :error
pid -> {:ok, ThousandIsland.Listener.listener_info(pid)}
end
end
@doc """
Gets a list of active connection processes. This is inherently a bit of a leaky notion in the
face of concurrency, as there may be connections coming and going during the period that this
function takes to run. Callers should account for the possibility that new connections may have
been made since / during this call, and that processes returned by this call may have since
completed. The order that connection processes are returned in is not specified
"""
@spec connection_pids(Supervisor.supervisor()) :: {:ok, [pid()]} | :error
def connection_pids(supervisor) do
case ThousandIsland.Server.acceptor_pool_supervisor_pid(supervisor) do
nil -> :error
acceptor_pool_pid -> {:ok, collect_connection_pids(acceptor_pool_pid)}
end
end
@doc """
Suspend the server. This will close the listening port, and will stop the acceptance of new
connections. Existing connections will stay connected and will continue to be processed.
The server can later be resumed by calling `resume/1`, or shut down via standard supervision
patterns.
If this function returns `:error`, it is unlikely that the server is in a useable state
Note that if you do not explicitly set a port (or if you set port to `0`), then the server will
bind to a different port when you resume it. This new port can be obtained as usual via the
`listener_info/1` function. This is not a concern if you explicitly set a port value when first
instantiating the server
"""
defdelegate suspend(supervisor), to: ThousandIsland.Server
@doc """
Resume a suspended server. This will reopen the listening port, and resume the acceptance of new
connections
"""
defdelegate resume(supervisor), to: ThousandIsland.Server
defp collect_connection_pids(acceptor_pool_pid) do
acceptor_pool_pid
|> ThousandIsland.AcceptorPoolSupervisor.acceptor_supervisor_pids()
|> Enum.reduce([], fn acceptor_sup_pid, acc ->
case ThousandIsland.AcceptorSupervisor.connection_sup_pid(acceptor_sup_pid) do
nil -> acc
connection_sup_pid -> connection_pids(connection_sup_pid, acc)
end
end)
end
defp connection_pids(connection_sup_pid, acc) do
connection_sup_pid
|> DynamicSupervisor.which_children()
|> Enum.reduce(acc, fn
{_, connection_pid, _, _}, acc when is_pid(connection_pid) ->
[connection_pid | acc]
_, acc ->
acc
end)
end
@doc """
Synchronously stops the given server, waiting up to the given number of milliseconds
for existing connections to finish up. Immediately upon calling this function,
the server stops listening for new connections, and then proceeds to wait until
either all existing connections have completed or the specified timeout has
elapsed.
"""
@spec stop(Supervisor.supervisor(), timeout()) :: :ok
def stop(supervisor, connection_wait \\ 15_000) do
Supervisor.stop(supervisor, :normal, connection_wait)
end
end

View File

@@ -0,0 +1,77 @@
defmodule ThousandIsland.Acceptor do
@moduledoc false
use Task, restart: :transient
@spec start_link(
{server :: Supervisor.supervisor(), parent :: Supervisor.supervisor(), pos_integer(),
ThousandIsland.ServerConfig.t()}
) :: {:ok, pid()}
def start_link(arg), do: Task.start_link(__MODULE__, :run, [arg])
@spec run(
{server :: Supervisor.supervisor(), parent :: Supervisor.supervisor(), pos_integer(),
ThousandIsland.ServerConfig.t()}
) :: no_return
def run({server_pid, parent_pid, acceptor_id, %ThousandIsland.ServerConfig{} = server_config}) do
ThousandIsland.ProcessLabel.set(:acceptor, server_config, acceptor_id)
listener_pid = ThousandIsland.Server.listener_pid(server_pid)
{listener_socket, listener_span} =
ThousandIsland.Listener.acceptor_info(listener_pid, acceptor_id)
connection_sup_pid = ThousandIsland.AcceptorSupervisor.connection_sup_pid(parent_pid)
acceptor_span = ThousandIsland.Telemetry.start_child_span(listener_span, :acceptor)
# Pre-create the handler config once to avoid Map.take on every connection (hot path)
handler_config = ThousandIsland.HandlerConfig.from_server_config(server_config)
accept(listener_socket, connection_sup_pid, server_config, handler_config, acceptor_span, 0)
end
defp accept(listener_socket, connection_sup_pid, server_config, handler_config, span, count) do
with {:ok, socket} <- server_config.transport_module.accept(listener_socket),
:ok <-
ThousandIsland.Connection.start(
connection_sup_pid,
socket,
server_config,
handler_config,
span
) do
accept(listener_socket, connection_sup_pid, server_config, handler_config, span, count + 1)
else
{:error, :too_many_connections} ->
ThousandIsland.Telemetry.span_event(span, :spawn_error)
accept(
listener_socket,
connection_sup_pid,
server_config,
handler_config,
span,
count + 1
)
{:error, reason} when reason in [:econnaborted, :einval] ->
ThousandIsland.Telemetry.span_event(span, reason)
accept(
listener_socket,
connection_sup_pid,
server_config,
handler_config,
span,
count + 1
)
{:error, :closed} ->
ThousandIsland.Telemetry.stop_span(span, %{connections: count})
{:error, reason} ->
ThousandIsland.Telemetry.stop_span(span, %{connections: count}, %{error: reason})
raise "Unexpected error in accept: #{inspect(reason)}"
end
end
end

View File

@@ -0,0 +1,54 @@
defmodule ThousandIsland.AcceptorPoolSupervisor do
@moduledoc false
use Supervisor
@spec start_link({server_pid :: pid, ThousandIsland.ServerConfig.t()}) :: Supervisor.on_start()
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg)
end
@spec acceptor_supervisor_pids(Supervisor.supervisor()) :: [pid()]
def acceptor_supervisor_pids(supervisor) do
supervisor
|> Supervisor.which_children()
|> Enum.reduce([], fn
{_, acceptor_pid, _, _}, acc when is_pid(acceptor_pid) -> [acceptor_pid | acc]
_, acc -> acc
end)
end
@spec suspend(Supervisor.supervisor()) :: :ok | :error
def suspend(pid) do
pid
|> acceptor_supervisor_pids()
|> Enum.map(&ThousandIsland.AcceptorSupervisor.suspend/1)
|> Enum.all?(&(&1 == :ok))
|> if(do: :ok, else: :error)
end
@spec resume(Supervisor.supervisor()) :: :ok | :error
def resume(pid) do
pid
|> acceptor_supervisor_pids()
|> Enum.map(&ThousandIsland.AcceptorSupervisor.resume/1)
|> Enum.all?(&(&1 == :ok))
|> if(do: :ok, else: :error)
end
@impl Supervisor
@spec init({server_pid :: pid, ThousandIsland.ServerConfig.t()}) ::
{:ok,
{Supervisor.sup_flags(),
[Supervisor.child_spec() | (old_erlang_child_spec :: :supervisor.child_spec())]}}
def init({server_pid, %ThousandIsland.ServerConfig{num_acceptors: num_acceptors} = config}) do
ThousandIsland.ProcessLabel.set(:acceptor_pool_supervisor, config)
1..num_acceptors
|> Enum.map(fn acceptor_id ->
child_spec = {ThousandIsland.AcceptorSupervisor, {server_pid, acceptor_id, config}}
Supervisor.child_spec(child_spec, id: "acceptor-#{acceptor_id}")
end)
|> Supervisor.init(strategy: :one_for_one)
end
end

View File

@@ -0,0 +1,59 @@
defmodule ThousandIsland.AcceptorSupervisor do
@moduledoc false
use Supervisor
@spec start_link({server_pid :: pid, pos_integer(), ThousandIsland.ServerConfig.t()}) ::
Supervisor.on_start()
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg)
end
@spec connection_sup_pid(Supervisor.supervisor()) :: pid() | nil
def connection_sup_pid(supervisor) do
supervisor
|> Supervisor.which_children()
|> Enum.find_value(fn
{:connection_sup, connection_sup_pid, _, _} when is_pid(connection_sup_pid) ->
connection_sup_pid
_ ->
false
end)
end
@spec suspend(Supervisor.supervisor()) :: :ok | :error
def suspend(pid) do
case Supervisor.terminate_child(pid, :acceptor) do
:ok -> :ok
{:error, :not_found} -> :error
end
end
@spec resume(Supervisor.supervisor()) :: :ok | :error
def resume(pid) do
case Supervisor.restart_child(pid, :acceptor) do
{:ok, _child} -> :ok
{:error, reason} when reason in [:running, :restarting] -> :ok
{:error, _reason} -> :error
end
end
@impl Supervisor
@spec init({server_pid :: pid, pos_integer(), ThousandIsland.ServerConfig.t()}) ::
{:ok,
{Supervisor.sup_flags(),
[Supervisor.child_spec() | (old_erlang_child_spec :: :supervisor.child_spec())]}}
def init({server_pid, acceptor_id, %ThousandIsland.ServerConfig{} = config}) do
ThousandIsland.ProcessLabel.set(:acceptor_supervisor, config)
children = [
{DynamicSupervisor, strategy: :one_for_one, max_children: config.num_connections}
|> Supervisor.child_spec(id: :connection_sup),
{ThousandIsland.Acceptor, {server_pid, self(), acceptor_id, config}}
|> Supervisor.child_spec(id: :acceptor)
]
Supervisor.init(children, strategy: :rest_for_one)
end
end

View File

@@ -0,0 +1,102 @@
defmodule ThousandIsland.Connection do
@moduledoc false
@spec start(
Supervisor.supervisor(),
ThousandIsland.Transport.socket(),
ThousandIsland.ServerConfig.t(),
ThousandIsland.HandlerConfig.t(),
ThousandIsland.Telemetry.t()
) ::
:ignore
| :ok
| {:ok, pid, info :: term}
| {:error, :too_many_connections | {:already_started, pid} | term}
def start(
sup_pid,
raw_socket,
%ThousandIsland.ServerConfig{} = server_config,
%ThousandIsland.HandlerConfig{} = handler_config,
acceptor_span
) do
# This is a multi-step process since we need to do a bit of work from within
# the process which owns the socket (us, at this point).
# First, capture the start time for telemetry purposes
start_time = ThousandIsland.Telemetry.monotonic_time()
# Start by defining the worker process which will eventually handle this socket
child_spec =
{server_config.handler_module,
{server_config.handler_options, server_config.genserver_options}}
|> Supervisor.child_spec(shutdown: server_config.shutdown_timeout)
# Then try to create it
do_start(
sup_pid,
child_spec,
raw_socket,
server_config,
handler_config,
acceptor_span,
start_time,
server_config.max_connections_retry_count
)
end
defp do_start(
sup_pid,
child_spec,
raw_socket,
server_config,
handler_config,
acceptor_span,
start_time,
retries
) do
case DynamicSupervisor.start_child(sup_pid, child_spec) do
{:ok, pid} ->
# Since this process owns the socket at this point, it needs to be the
# one to make this call. connection_pid is sitting and waiting for the
# word from us to start processing, in order to ensure that we've made
# the following call. Note that we purposefully do not match on the
# return from this function; if there's an error the connection process
# will see it, but it's no longer our problem if that's the case
_ = handler_config.transport_module.controlling_process(raw_socket, pid)
# Now that we have transferred ownership over to the new process, send a message to the
# new process with all the info it needs to start working with the socket (note that the
# new process will still need to handshake with the remote end). handler_config was
# pre-created by the acceptor to avoid Map.take on every connection.
send(pid, {:thousand_island_ready, raw_socket, handler_config, acceptor_span, start_time})
:ok
{:error, :max_children} when retries > 0 ->
# We're in a tricky spot here; we have a client connection in hand, but no room to put it
# into the connection supervisor. We try to wait a maximum number of times to see if any
# room opens up before we give up
Process.sleep(server_config.max_connections_retry_wait)
do_start(
sup_pid,
child_spec,
raw_socket,
server_config,
handler_config,
acceptor_span,
start_time,
retries - 1
)
{:error, :max_children} ->
# We gave up trying to find room for this connection in our supervisor.
# Close the raw socket here and let the acceptor process handle propagating the error
handler_config.transport_module.close(raw_socket)
{:error, :too_many_connections}
other ->
other
end
end
end

View File

@@ -0,0 +1,600 @@
defmodule ThousandIsland.Handler do
@moduledoc """
`ThousandIsland.Handler` defines the behaviour required of the application layer of a Thousand Island server. When starting a
Thousand Island server, you must pass the name of a module implementing this behaviour as the `handler_module` parameter.
Thousand Island will then use the specified module to handle each connection that is made to the server.
The lifecycle of a Handler instance is as follows:
1. After a client connection to a Thousand Island server is made, Thousand Island will complete the initial setup of the
connection (performing a TLS handshake, for example), and then call `c:handle_connection/2`.
2. A handler implementation may choose to process a client connection within the `c:handle_connection/2` callback by
calling functions against the passed `ThousandIsland.Socket`. In many cases, this may be all that may be required of
an implementation & the value `{:close, state}` can be returned which will cause Thousand Island to close the connection
to the client.
3. In cases where the server wishes to keep the connection open and wait for subsequent requests from the client on the
same socket, it may elect to return `{:continue, state}`. This will cause Thousand Island to wait for client data
asynchronously; `c:handle_data/3` will be invoked when the client sends more data.
4. In the meantime, the process which is hosting connection is idle & able to receive messages sent from elsewhere in your
application as needed. The implementation included in the `use ThousandIsland.Handler` macro uses a `GenServer` structure,
so you may implement such behaviour via standard `GenServer` patterns. Note that in these cases that state is provided (and
must be returned) in a `{socket, state}` format, where the second tuple is the same state value that is passed to the various `handle_*` callbacks
defined on this behaviour. It also critical to maintain the socket's `read_timeout` value by
ensuring the relevant timeout value is returned as your callback's final argument. Both of these
concerns are illustrated in the following example:
```elixir
defmodule ExampleHandler do
use ThousandIsland.Handler
# ...handle_data and other Handler callbacks
@impl GenServer
def handle_call(msg, from, {socket, state}) do
# Do whatever you'd like with msg & from
{:reply, :ok, {socket, state}, socket.read_timeout}
end
@impl GenServer
def handle_cast(msg, {socket, state}) do
# Do whatever you'd like with msg
{:noreply, {socket, state}, socket.read_timeout}
end
@impl GenServer
def handle_info(msg, {socket, state}) do
# Do whatever you'd like with msg
{:noreply, {socket, state}, socket.read_timeout}
end
end
```
It is fully supported to intermix synchronous `ThousandIsland.Socket.recv` calls with async return values from `c:handle_connection/2`
and `c:handle_data/3` callbacks.
# Example
A simple example of a Hello World server is as follows:
```elixir
defmodule HelloWorld do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
ThousandIsland.Socket.send(socket, "Hello, World")
{:close, state}
end
end
```
Another example of a server that echoes back all data sent to it is as follows:
```elixir
defmodule Echo do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
ThousandIsland.Socket.send(socket, data)
{:continue, state}
end
end
```
Note that in this example there is no `c:handle_connection/2` callback defined. The default implementation of this
callback will simply return `{:continue, state}`, which is appropriate for cases where the client is the first
party to communicate.
Another example of a server which can send and receive messages asynchronously is as follows:
```elixir
defmodule Messenger do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_data(msg, _socket, state) do
IO.puts(msg)
{:continue, state}
end
def handle_info({:send, msg}, {socket, state}) do
ThousandIsland.Socket.send(socket, msg)
{:noreply, {socket, state}, socket.read_timeout}
end
end
```
Note that in this example we make use of the fact that the handler process is really just a GenServer to send it messages
which are able to make use of the underlying socket. This allows for bidirectional sending and receiving of messages in
an asynchronous manner.
You can pass options to the default handler underlying `GenServer` by passing a `genserver_options` key to `ThousandIsland.start_link/1`
containing `t:GenServer.options/0` to be passed to the last argument of `GenServer.start_link/3`.
Please note that you should not pass the `name` `t:GenServer.option/0`. If you need to register handler processes for
later lookup and use, you should perform process registration in `handle_connection/2`, ensuring the handler process is
registered only after the underlying connection is established and you have access to the connection socket and metadata
via `ThousandIsland.Socket.peername/1`.
For example, using a custom process registry via `Registry`:
```elixir
defmodule Messenger do
use ThousandIsland.Handler
@impl ThousandIsland.Handler
def handle_connection(socket, state) do
{:ok, {ip, port}} = ThousandIsland.Socket.peername(socket)
{:ok, _pid} = Registry.register(MessengerRegistry, {state[:my_key], address}, nil)
{:continue, state}
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
ThousandIsland.Socket.send(socket, data)
{:continue, state}
end
end
```
This example assumes you have started a `Registry` and registered it under the name `MessengerRegistry`.
# When Handler Isn't Enough
The `use ThousandIsland.Handler` implementation should be flexible enough to power just about any handler, however if
this should not be the case for you, there is an escape hatch available. If you require more flexibility than the
`ThousandIsland.Handler` behaviour provides, you are free to specify any module which implements `start_link/1` as the
`handler_module` parameter. The process of getting from this new process to a ready-to-use socket is somewhat
delicate, however. The steps required are as follows:
1. Thousand Island calls `start_link/1` on the configured `handler_module`, passing in a tuple
consisting of the configured handler and genserver opts. This function is expected to return a
conventional `GenServer.on_start()` style tuple. Note that this newly created process is not
passed the connection socket immediately.
2. The raw `t:ThousandIsland.Transport.socket()` socket will be passed to the new process via a
message of the form `{:thousand_island_ready, raw_socket, handler_config, acceptor_span,
start_time}`.
3. Your implementation must turn this into a `to:ThousandIsland.Socket.t()` socket by using the
`ThousandIsland.Socket.new/3` call.
4. Your implementation must then call `ThousandIsland.Socket.handshake/1` with the socket as the
sole argument in order to finalize the setup of the socket.
5. The socket is now ready to use.
In addition to this process, there are several other considerations to be aware of:
* The underlying socket is closed automatically when the handler process ends.
* Handler processes should have a restart strategy of `:temporary` to ensure that Thousand Island does not attempt to
restart crashed handlers.
* Handler processes should trap exit if possible so that existing connections can be given a chance to cleanly shut
down when shutting down a Thousand Island server instance.
* Some of the `:connection` family of telemetry span events are emitted by the
`ThousandIsland.Handler` implementation. If you use your own implementation in its place it is
likely that such spans will not behave as expected.
"""
@typedoc "The possible ways to indicate a timeout when returning values to Thousand Island"
@type timeout_options :: timeout() | {:persistent, timeout()}
@typedoc "The value returned by `c:handle_connection/2` and `c:handle_data/3`"
@type handler_result ::
{:continue, state :: term()}
| {:continue, state :: term(), timeout_options() | {:continue, term()}}
| {:switch_transport, {module(), upgrade_opts :: [term()]}, state :: term()}
| {:switch_transport, {module(), upgrade_opts :: [term()]}, state :: term(),
timeout_options() | {:continue, term()}}
| {:close, state :: term()}
| {:error, term(), state :: term()}
@doc """
This callback is called shortly after a client connection has been made, immediately after the socket handshake process has
completed. It is called with the server's configured `handler_options` value as initial state. Handlers may choose to
interact synchronously with the socket in this callback via calls to various `ThousandIsland.Socket` functions.
The value returned by this callback causes Thousand Island to proceed in one of several ways:
* Returning `{:close, state}` will cause Thousand Island to close the socket & call the `c:handle_close/2` callback to
allow final cleanup to be done.
* Returning `{:continue, state}` will cause Thousand Island to switch the socket to an asynchronous mode. When the
client subsequently sends data (or if there is already unread data waiting from the client), Thousand Island will call
`c:handle_data/3` to allow this data to be processed.
* Returning `{:continue, state, timeout}` is identical to the previous case with the
addition of a timeout. If `timeout` milliseconds passes with no data being received or messages
being sent to the process, the socket will be closed and `c:handle_timeout/2` will be called.
Note that this timeout is not persistent; it applies only to the interval until the next message
is received. In order to set a persistent timeout for all future messages (essentially
overwriting the value of `read_timeout` that was set at server startup), a value of
`{:persistent, timeout}` may be returned.
* Returning `{:continue, state, {:continue, continue}}` is identical to the previous case with the
addition of a `c:GenServer.handle_continue/2` callback being made immediately after, in line with
similar behaviour on `GenServer` callbacks.
* Returning `{:switch_transport, {module, opts}, state}` will cause Thousand Island to try switching the transport of the
current socket. The `module` should be an Elixir module that implements the `ThousandIsland.Transport` behaviour.
Thousand Island will call `c:ThousandIsland.Transport.upgrade/2` for the given module to upgrade the transport in-place.
After a successful upgrade Thousand Island will switch the socket to an asynchronous mode, as if `{:continue, state}`
was returned. As with `:continue` return values, there are also timeout-specifying variants of
this return value.
* Returning `{:error, reason, state}` will cause Thousand Island to close the socket & call the `c:handle_error/3` callback to
allow final cleanup to be done.
"""
@callback handle_connection(socket :: ThousandIsland.Socket.t(), state :: term()) ::
handler_result()
@doc """
This callback is called whenever client data is received after `c:handle_connection/2` or `c:handle_data/3` have returned an
`{:continue, state}` tuple. The data received is passed as the first argument, and handlers may choose to interact
synchronously with the socket in this callback via calls to various `ThousandIsland.Socket` functions.
The value returned by this callback causes Thousand Island to proceed in one of several ways:
* Returning `{:close, state}` will cause Thousand Island to close the socket & call the `c:handle_close/2` callback to
allow final cleanup to be done.
* Returning `{:continue, state}` will cause Thousand Island to switch the socket to an asynchronous mode. When the
client subsequently sends data (or if there is already unread data waiting from the client), Thousand Island will call
`c:handle_data/3` to allow this data to be processed.
* Returning `{:continue, state, timeout}` is identical to the previous case with the
addition of a timeout. If `timeout` milliseconds passes with no data being received or messages
being sent to the process, the socket will be closed and `c:handle_timeout/2` will be called.
Note that this timeout is not persistent; it applies only to the interval until the next message
is received. In order to set a persistent timeout for all future messages (essentially
overwriting the value of `read_timeout` that was set at server startup), a value of
`{:persistent, timeout}` may be returned.
* Returning `{:continue, state, {:continue, continue}}` is identical to the previous case with the
addition of a `c:GenServer.handle_continue/2` callback being made immediately after, in line with
similar behaviour on `GenServer` callbacks.
* Returning `{:error, reason, state}` will cause Thousand Island to close the socket & call the `c:handle_error/3` callback to
allow final cleanup to be done.
"""
@callback handle_data(data :: binary(), socket :: ThousandIsland.Socket.t(), state :: term()) ::
handler_result()
@doc """
This callback is called when the underlying socket is closed by the remote end; it should perform any cleanup required
as it is the last callback called before the process backing this connection is terminated. The underlying socket
has already been closed by the time this callback is called. The return value is ignored.
This callback is not called if the connection is explicitly closed via `ThousandIsland.Socket.close/1`, however it
will be called in cases where `handle_connection/2` or `handle_data/3` return a `{:close, state}` tuple.
"""
@callback handle_close(socket :: ThousandIsland.Socket.t(), state :: term()) :: term()
@doc """
This callback is called when the underlying socket encounters an error; it should perform any cleanup required
as it is the last callback called before the process backing this connection is terminated. The underlying socket
has already been closed by the time this callback is called. The return value is ignored.
In addition to socket level errors, this callback is also called in cases where `handle_connection/2` or `handle_data/3`
return a `{:error, reason, state}` tuple, or when connection handshaking (typically TLS
negotiation) fails.
"""
@callback handle_error(reason :: any(), socket :: ThousandIsland.Socket.t(), state :: term()) ::
term()
@doc """
This callback is called when the server process itself is being shut down; it should perform any cleanup required
as it is the last callback called before the process backing this connection is terminated. The underlying socket
has NOT been closed by the time this callback is called. The return value is ignored.
This callback is only called when the shutdown reason is `:normal`, and is subject to the same caveats described
in `c:GenServer.terminate/2`.
"""
@callback handle_shutdown(socket :: ThousandIsland.Socket.t(), state :: term()) :: term()
@doc """
This callback is called when a handler process has gone more than `timeout` ms without receiving
either remote data or a local message. The value used for `timeout` defaults to the
`read_timeout` value specified at server startup, and may be overridden on a one-shot or
persistent basis based on values returned from `c:handle_connection/2` or `c:handle_data/3`
calls. Note that it is NOT called on explicit `ThousandIsland.Socket.recv/3` calls as they have
their own timeout semantics. The underlying socket has NOT been closed by the time this callback
is called. The return value is ignored.
"""
@callback handle_timeout(socket :: ThousandIsland.Socket.t(), state :: term()) :: term()
@optional_callbacks handle_connection: 2,
handle_data: 3,
handle_close: 2,
handle_error: 3,
handle_shutdown: 2,
handle_timeout: 2
@spec __using__(any) :: Macro.t()
defmacro __using__(_opts) do
quote location: :keep do
@behaviour ThousandIsland.Handler
use GenServer, restart: :temporary
@spec start_link({handler_options :: term(), GenServer.options()}) :: GenServer.on_start()
def start_link({handler_options, genserver_options}) do
GenServer.start_link(__MODULE__, handler_options, genserver_options)
end
unquote(genserver_impl())
unquote(handler_impl())
end
end
@doc false
defmacro add_handle_info_fallback(_module) do
quote do
def handle_info({msg, _raw_socket, _data}, _state) when msg in [:tcp, :ssl] do
raise """
The callback's `state` doesn't match the expected `{socket, state}` form.
Please ensure that you are returning a `{socket, state}` tuple from any
`GenServer.handle_*` callbacks you have implemented
"""
end
end
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def genserver_impl do
quote do
@impl true
def init(handler_options) do
Process.flag(:trap_exit, true)
{:ok, {nil, handler_options}}
end
@impl true
def handle_info(
{:thousand_island_ready, raw_socket, handler_config, acceptor_span, start_time},
{nil, state}
) do
{ip, port} =
case handler_config.transport_module.peername(raw_socket) do
{:ok, remote_info} ->
remote_info
{:error, reason} ->
# the socket has been prematurely closed by the client, we can't do anything with it
# so we just close the socket, stop the GenServer with the error reason and move on.
_ = handler_config.transport_module.close(raw_socket)
throw({:stop, {:shutdown, {:premature_conn_closing, reason}}, {raw_socket, state}})
end
ThousandIsland.ProcessLabel.set(:connection, handler_config, {ip, port})
span_meta = %{remote_address: ip, remote_port: port}
connection_span =
ThousandIsland.Telemetry.start_child_span(
acceptor_span,
:connection,
%{monotonic_time: start_time},
span_meta
)
socket = ThousandIsland.Socket.new(raw_socket, handler_config, connection_span)
ThousandIsland.Telemetry.span_event(connection_span, :ready)
case ThousandIsland.Socket.handshake(socket) do
{:ok, socket} -> {:noreply, {socket, state}, {:continue, :handle_connection}}
{:error, reason} -> {:stop, {:shutdown, {:handshake, reason}}, {socket, state}}
end
catch
{:stop, _, _} = stop -> stop
end
def handle_info(
{msg, raw_socket, data},
{%ThousandIsland.Socket{socket: raw_socket} = socket, state}
)
when msg in [:tcp, :ssl] do
ThousandIsland.Telemetry.untimed_span_event(socket.span, :async_recv, %{data: data})
__MODULE__.handle_data(data, socket, state)
|> ThousandIsland.Handler.handle_continuation(socket)
end
def handle_info(
{msg, raw_socket},
{%ThousandIsland.Socket{socket: raw_socket} = socket, state}
)
when msg in [:tcp_closed, :ssl_closed] do
{:stop, {:shutdown, :peer_closed}, {socket, state}}
end
def handle_info(
{msg, raw_socket, reason},
{%ThousandIsland.Socket{socket: raw_socket} = socket, state}
)
when msg in [:tcp_error, :ssl_error] do
{:stop, reason, {socket, state}}
end
def handle_info(:timeout, {%ThousandIsland.Socket{} = socket, state}) do
{:stop, {:shutdown, :timeout}, {socket, state}}
end
@before_compile {ThousandIsland.Handler, :add_handle_info_fallback}
# Use a continue pattern here so that we have committed the socket
# to state in case the `c:handle_connection/2` callback raises an error.
# This ensures that the `c:terminate/2` calls below are able to properly
# close down the process
@impl true
def handle_continue(:handle_connection, {%ThousandIsland.Socket{} = socket, state}) do
__MODULE__.handle_connection(socket, state)
|> ThousandIsland.Handler.handle_continuation(socket)
end
# Called if the remote end closed the connection before we could initialize it
@impl true
def terminate({:shutdown, {:premature_conn_closing, _reason}}, {_raw_socket, _state}) do
:ok
end
# Called by GenServer if we hit our read_timeout. Socket is still open
def terminate({:shutdown, :timeout}, {%ThousandIsland.Socket{} = socket, state}) do
_ = __MODULE__.handle_timeout(socket, state)
ThousandIsland.Handler.do_socket_close(socket, :timeout)
end
# Called if we're being shutdown in an orderly manner. Socket is still open
def terminate(:shutdown, {%ThousandIsland.Socket{} = socket, state}) do
_ = __MODULE__.handle_shutdown(socket, state)
ThousandIsland.Handler.do_socket_close(socket, :shutdown)
end
# Called if the socket encountered an error during handshaking
def terminate({:shutdown, {:handshake, reason}}, {%ThousandIsland.Socket{} = socket, state}) do
_ = __MODULE__.handle_error(reason, socket, state)
ThousandIsland.Handler.do_socket_close(socket, reason)
end
# Called if the socket encountered an error and we are configured to shutdown silently.
# Socket is closed
def terminate(
{:shutdown, {:silent_termination, reason}},
{%ThousandIsland.Socket{} = socket, state}
) do
_ = __MODULE__.handle_error(reason, socket, state)
ThousandIsland.Handler.do_socket_close(socket, reason)
end
# Called if the socket encountered an error during upgrading
def terminate({:shutdown, {:upgrade, reason}}, {socket, state}) do
_ = __MODULE__.handle_error(reason, socket, state)
ThousandIsland.Handler.do_socket_close(socket, reason)
end
# Called if the remote end shut down the connection, or if the local end closed the
# connection by returning a `{:close,...}` tuple (in which case the socket will be open)
def terminate({:shutdown, reason}, {%ThousandIsland.Socket{} = socket, state}) do
_ = __MODULE__.handle_close(socket, state)
ThousandIsland.Handler.do_socket_close(socket, reason)
end
# Called if the socket encountered an error. Socket is closed
def terminate(reason, {%ThousandIsland.Socket{} = socket, state}) do
_ = __MODULE__.handle_error(reason, socket, state)
ThousandIsland.Handler.do_socket_close(socket, reason)
end
# This clause could happen if we do not have a socket defined in state (either because the
# process crashed before setting it up, or because the user sent an invalid state)
def terminate(_reason, _state) do
:ok
end
end
end
def handler_impl do
quote do
@impl true
def handle_connection(_socket, state), do: {:continue, state}
@impl true
def handle_data(_data, _socket, state), do: {:continue, state}
@impl true
def handle_close(_socket, _state), do: :ok
@impl true
def handle_error(_error, _socket, _state), do: :ok
@impl true
def handle_shutdown(_socket, _state), do: :ok
@impl true
def handle_timeout(_socket, _state), do: :ok
defoverridable ThousandIsland.Handler
end
end
@spec do_socket_close(
ThousandIsland.Socket.t(),
reason :: :shutdown | :local_closed | term()
) :: :ok
@doc false
def do_socket_close(socket, reason) do
measurements =
case ThousandIsland.Socket.getstat(socket) do
{:ok, stats} ->
%{
send_oct: stats[:send_oct],
send_cnt: stats[:send_cnt],
recv_oct: stats[:recv_oct],
recv_cnt: stats[:recv_cnt]
}
_ ->
%{}
end
metadata =
if reason in [:shutdown, :local_closed, :peer_closed], do: %{}, else: %{error: reason}
_ = ThousandIsland.Socket.close(socket)
ThousandIsland.Telemetry.stop_span(socket.span, measurements, metadata)
end
@doc false
def handle_continuation(continuation, socket) do
case continuation do
{:continue, state} ->
_ = ThousandIsland.Socket.setopts(socket, active: :once)
{:noreply, {socket, state}, socket.read_timeout}
{:continue, state, {:continue, continue}} ->
_ = ThousandIsland.Socket.setopts(socket, active: :once)
{:noreply, {socket, state}, {:continue, continue}}
{:continue, state, {:persistent, timeout}} ->
socket = %{socket | read_timeout: timeout}
_ = ThousandIsland.Socket.setopts(socket, active: :once)
{:noreply, {socket, state}, timeout}
{:continue, state, timeout} ->
_ = ThousandIsland.Socket.setopts(socket, active: :once)
{:noreply, {socket, state}, timeout}
{:switch_transport, {module, upgrade_opts}, state} ->
handle_switch_continuation(socket, module, upgrade_opts, state, socket.read_timeout)
{:switch_transport, {module, upgrade_opts}, state, {:continue, continue}} ->
handle_switch_continuation(socket, module, upgrade_opts, state, {:continue, continue})
{:switch_transport, {module, upgrade_opts}, state, {:persistent, timeout}} ->
socket = %{socket | read_timeout: timeout}
handle_switch_continuation(socket, module, upgrade_opts, state, timeout)
{:switch_transport, {module, upgrade_opts}, state, timeout} ->
handle_switch_continuation(socket, module, upgrade_opts, state, timeout)
{:close, state} ->
{:stop, {:shutdown, :local_closed}, {socket, state}}
{:error, :timeout, state} ->
{:stop, {:shutdown, :timeout}, {socket, state}}
{:error, reason, state} ->
if socket.silent_terminate_on_error do
{:stop, {:shutdown, {:silent_termination, reason}}, {socket, state}}
else
{:stop, reason, {socket, state}}
end
end
end
defp handle_switch_continuation(socket, module, upgrade_opts, state, timeout_or_continue) do
case ThousandIsland.Socket.upgrade(socket, module, upgrade_opts) do
{:ok, socket} ->
_ = ThousandIsland.Socket.setopts(socket, active: :once)
{:noreply, {socket, state}, timeout_or_continue}
{:error, reason} ->
{:stop, {:shutdown, {:upgrade, reason}}, {socket, state}}
end
end
end

View File

@@ -0,0 +1,31 @@
defmodule ThousandIsland.HandlerConfig do
@moduledoc """
A minimal config struct containing only the fields needed by connection handlers.
This is used internally by `ThousandIsland.Handler`
"""
@enforce_keys [:handler_module, :transport_module, :read_timeout, :silent_terminate_on_error]
defstruct @enforce_keys
@type t :: %__MODULE__{
handler_module: nil,
transport_module: module(),
read_timeout: timeout(),
silent_terminate_on_error: boolean()
}
@doc """
Creates a HandlerConfig from a ServerConfig, extracting only the fields needed
by connection handlers. This should be called once per acceptor at initialization.
"""
@spec from_server_config(ThousandIsland.ServerConfig.t()) :: t()
def from_server_config(%ThousandIsland.ServerConfig{} = config) do
%__MODULE__{
handler_module: config.handler_module,
transport_module: config.transport_module,
read_timeout: config.read_timeout,
silent_terminate_on_error: config.silent_terminate_on_error
}
end
end

View File

@@ -0,0 +1,109 @@
defmodule ThousandIsland.Listener do
@moduledoc false
use GenServer, restart: :transient
@type state :: %{
listener_sockets: [{pos_integer(), ThousandIsland.Transport.listener_socket()}],
listener_span: ThousandIsland.Telemetry.t(),
local_info: ThousandIsland.Transport.socket_info()
}
@spec start_link(ThousandIsland.ServerConfig.t()) :: GenServer.on_start()
def start_link(config), do: GenServer.start_link(__MODULE__, config)
@spec stop(GenServer.server()) :: :ok
def stop(server), do: GenServer.stop(server)
@spec listener_info(GenServer.server()) :: ThousandIsland.Transport.socket_info()
def listener_info(server), do: GenServer.call(server, :listener_info)
@spec acceptor_info(GenServer.server(), pos_integer()) ::
{ThousandIsland.Transport.listener_socket(), ThousandIsland.Telemetry.t()}
def acceptor_info(server, acceptor_id),
do: GenServer.call(server, {:acceptor_info, acceptor_id})
@impl GenServer
@spec init(ThousandIsland.ServerConfig.t()) :: {:ok, state} | {:stop, reason :: term}
def init(%ThousandIsland.ServerConfig{} = server_config) do
case start_listen_sockets(server_config) do
{:ok, listener_sockets, local_info} ->
ThousandIsland.ProcessLabel.set(:listener, server_config)
span_metadata = %{
handler: server_config.handler_module,
local_address: elem(local_info, 0),
local_port: elem(local_info, 1),
transport_module: server_config.transport_module,
transport_options: server_config.transport_options
}
listener_span = ThousandIsland.Telemetry.start_span(:listener, %{}, span_metadata)
{:ok,
%{
listener_sockets: listener_sockets,
local_info: local_info,
listener_span: listener_span
}}
{:error, reason} ->
{:stop, reason}
end
end
defp start_listen_sockets(%ThousandIsland.ServerConfig{} = server_config) do
num_sockets = server_config.num_listen_sockets
sockets =
for socket_id <- 1..num_sockets do
case server_config.transport_module.listen(
server_config.port,
server_config.transport_options
) do
{:ok, socket} -> {socket_id, socket}
{:error, reason} -> throw({:error, reason})
end
end
# Get local info from first socket
{1, first_socket} = List.keyfind(sockets, 1, 0)
case server_config.transport_module.sockname(first_socket) do
{:ok, {ip, port}} ->
{:ok, sockets, {ip, port}}
{:error, reason} ->
# Cleanup all sockets on error
Enum.each(sockets, fn {_, socket} ->
server_config.transport_module.close(socket)
end)
{:error, reason}
end
catch
{:error, reason} ->
{:error, reason}
end
@impl GenServer
@spec handle_call(:listener_info | {:acceptor_info, pos_integer()}, any, state) ::
{:reply,
ThousandIsland.Transport.socket_info()
| {ThousandIsland.Transport.listener_socket(), ThousandIsland.Telemetry.t()}, state}
def handle_call(:listener_info, _from, state), do: {:reply, state.local_info, state}
def handle_call({:acceptor_info, acceptor_id}, _from, state) do
num_listen_sockets = length(state.listener_sockets)
socket_id = rem(acceptor_id - 1, num_listen_sockets) + 1
{^socket_id, listener_socket} = List.keyfind(state.listener_sockets, socket_id, 0)
{:reply, {listener_socket, state.listener_span}, state}
end
@impl GenServer
@spec terminate(reason, state) :: :ok
when reason: :normal | :shutdown | {:shutdown, term} | term
def terminate(_reason, state) do
ThousandIsland.Telemetry.stop_span(state.listener_span)
end
end

View File

@@ -0,0 +1,149 @@
defmodule ThousandIsland.Logger do
@moduledoc """
Logging conveniences for Thousand Island servers
Allows dynamically adding and altering the log level used to trace connections
within a Thousand Island server via the use of telemetry hooks. Should you wish
to do your own logging or tracking of these events, a complete list of the
telemetry events emitted by Thousand Island is described in the module
documentation for `ThousandIsland.Telemetry`.
"""
require Logger
@typedoc "Supported log levels"
@type log_level :: :error | :info | :debug | :trace
@doc """
Start logging Thousand Island at the specified log level. Valid values for log
level are `:error`, `:info`, `:debug`, and `:trace`. Enabling a given log
level implicitly enables all higher log levels as well.
"""
@spec attach_logger(log_level()) :: :ok | {:error, :already_exists}
def attach_logger(:error) do
events = [
[:thousand_island, :acceptor, :spawn_error],
[:thousand_island, :acceptor, :econnaborted]
]
:telemetry.attach_many("#{__MODULE__}.error", events, &__MODULE__.log_error/4, nil)
end
def attach_logger(:info) do
_ = attach_logger(:error)
events = [
[:thousand_island, :listener, :start],
[:thousand_island, :listener, :stop]
]
:telemetry.attach_many("#{__MODULE__}.info", events, &__MODULE__.log_info/4, nil)
end
def attach_logger(:debug) do
_ = attach_logger(:info)
events = [
[:thousand_island, :acceptor, :start],
[:thousand_island, :acceptor, :stop],
[:thousand_island, :connection, :start],
[:thousand_island, :connection, :stop]
]
:telemetry.attach_many("#{__MODULE__}.debug", events, &__MODULE__.log_debug/4, nil)
end
def attach_logger(:trace) do
_ = attach_logger(:debug)
events = [
[: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]
]
:telemetry.attach_many("#{__MODULE__}.trace", events, &__MODULE__.log_trace/4, nil)
end
@doc """
Stop logging Thousand Island at the specified log level. Disabling a given log
level implicitly disables all lower log levels as well.
"""
@spec detach_logger(log_level()) :: :ok | {:error, :not_found}
def detach_logger(:error) do
_ = detach_logger(:info)
:telemetry.detach("#{__MODULE__}.error")
end
def detach_logger(:info) do
_ = detach_logger(:debug)
:telemetry.detach("#{__MODULE__}.info")
end
def detach_logger(:debug) do
_ = detach_logger(:trace)
:telemetry.detach("#{__MODULE__}.debug")
end
def detach_logger(:trace) do
:telemetry.detach("#{__MODULE__}.trace")
end
@doc false
@spec log_error(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata(),
:telemetry.handler_config()
) :: :ok
def log_error(event, measurements, metadata, _config) do
Logger.error(
"#{inspect(event)} metadata: #{inspect(metadata)}, measurements: #{inspect(measurements)}"
)
end
@doc false
@spec log_info(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata(),
:telemetry.handler_config()
) :: :ok
def log_info(event, measurements, metadata, _config) do
Logger.info(
"#{inspect(event)} metadata: #{inspect(metadata)}, measurements: #{inspect(measurements)}"
)
end
@doc false
@spec log_debug(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata(),
:telemetry.handler_config()
) :: :ok
def log_debug(event, measurements, metadata, _config) do
Logger.debug(
"#{inspect(event)} metadata: #{inspect(metadata)}, measurements: #{inspect(measurements)}"
)
end
@doc false
@spec log_trace(
:telemetry.event_name(),
:telemetry.event_measurements(),
:telemetry.event_metadata(),
:telemetry.handler_config()
) :: :ok
def log_trace(event, measurements, metadata, _config) do
Logger.debug(
"#{inspect(event)} metadata: #{inspect(metadata)}, measurements: #{inspect(measurements)}"
)
end
end

View File

@@ -0,0 +1,52 @@
defmodule ThousandIsland.ProcessLabel do
@moduledoc false
# Provides compile-time conditional support for Process.set_label/1
# which was introduced in Elixir 1.17.0 and OTP 27.
@supports_labels Version.match?(System.version(), ">= 1.17.0") and
String.to_integer(System.otp_release()) >= 27
@type config() :: ThousandIsland.ServerConfig.t() | ThousandIsland.HandlerConfig.t()
if @supports_labels do
@doc """
Sets a process label if the current Elixir version supports it (>= 1.17).
"""
@spec set(atom(), config(), term()) ::
:ok
def set(name, %ThousandIsland.ServerConfig{} = config, state) when is_atom(name) do
Process.set_label({:thousand_island, name, {{config.port, config.handler_module}, state}})
end
def set(name, %ThousandIsland.HandlerConfig{} = config, state) when is_atom(name) do
Process.set_label({:thousand_island, name, {config.handler_module, state}})
end
@doc """
Sets a process label if the current Elixir version supports it (>= 1.17).
"""
@spec set(atom(), term()) :: :ok
def set(name, %ThousandIsland.ServerConfig{} = config) when is_atom(name) do
Process.set_label({:thousand_island, name, {config.port, config.handler_module}})
end
def set(name, state) when is_atom(name) do
Process.set_label({:thousand_island, name, state})
end
else
@doc """
No-op on Elixir versions < 1.17 that don't support Process.set_label/1.
"""
@spec set(atom(), config(), term()) :: :ok
def set(_, _, _) do
:ok
end
@doc """
No-op on Elixir versions < 1.17 that don't support Process.set_label/1.
"""
@spec set(atom(), term()) :: :ok
def set(_, _) do
:ok
end
end
end

View File

@@ -0,0 +1,88 @@
defmodule ThousandIsland.Server do
@moduledoc false
use Supervisor
@spec start_link(ThousandIsland.ServerConfig.t()) :: Supervisor.on_start()
def start_link(%ThousandIsland.ServerConfig{} = config) do
Supervisor.start_link(__MODULE__, config, config.supervisor_options)
end
@spec listener_pid(Supervisor.supervisor()) :: pid() | nil
def listener_pid(supervisor) do
supervisor
|> Supervisor.which_children()
|> Enum.find_value(fn
{:listener, listener_pid, _, _} when is_pid(listener_pid) ->
listener_pid
_ ->
false
end)
end
@spec acceptor_pool_supervisor_pid(Supervisor.supervisor()) :: pid() | nil
def acceptor_pool_supervisor_pid(supervisor) do
supervisor
|> Supervisor.which_children()
|> Enum.find_value(fn
{:acceptor_pool_supervisor, acceptor_pool_sup_pid, _, _}
when is_pid(acceptor_pool_sup_pid) ->
acceptor_pool_sup_pid
_ ->
false
end)
end
@spec suspend(Supervisor.supervisor()) :: :ok | :error
def suspend(pid) do
with pool_sup_pid when is_pid(pool_sup_pid) <- acceptor_pool_supervisor_pid(pid),
:ok <- ThousandIsland.AcceptorPoolSupervisor.suspend(pool_sup_pid),
:ok <- Supervisor.terminate_child(pid, :shutdown_listener),
:ok <- Supervisor.terminate_child(pid, :listener) do
:ok
else
_ -> :error
end
end
@spec resume(Supervisor.supervisor()) :: :ok | :error
def resume(pid) do
with :ok <- wrap_restart_child(pid, :listener),
:ok <- wrap_restart_child(pid, :shutdown_listener),
pool_sup_pid when is_pid(pool_sup_pid) <- acceptor_pool_supervisor_pid(pid),
:ok <- ThousandIsland.AcceptorPoolSupervisor.resume(pool_sup_pid) do
:ok
else
_ -> :error
end
end
defp wrap_restart_child(pid, id) do
case Supervisor.restart_child(pid, id) do
{:ok, _child} -> :ok
{:error, reason} when reason in [:running, :restarting] -> :ok
{:error, _reason} -> :error
end
end
@impl Supervisor
@spec init(ThousandIsland.ServerConfig.t()) ::
{:ok,
{Supervisor.sup_flags(),
[Supervisor.child_spec() | (old_erlang_child_spec :: :supervisor.child_spec())]}}
def init(config) do
ThousandIsland.ProcessLabel.set(:server, config)
children = [
{ThousandIsland.Listener, config} |> Supervisor.child_spec(id: :listener),
{ThousandIsland.AcceptorPoolSupervisor, {self(), config}}
|> Supervisor.child_spec(id: :acceptor_pool_supervisor),
{ThousandIsland.ShutdownListener, {self(), config}}
|> Supervisor.child_spec(id: :shutdown_listener)
]
Supervisor.init(children, strategy: :rest_for_one)
end
end

View File

@@ -0,0 +1,77 @@
defmodule ThousandIsland.ServerConfig do
@moduledoc """
Encapsulates the configuration of a ThousandIsland server instance
This is used internally by `ThousandIsland.Handler`
"""
@typedoc "A set of configuration parameters for a ThousandIsland server instance"
@type t :: %__MODULE__{
port: :inet.port_number(),
transport_module: ThousandIsland.transport_module(),
transport_options: ThousandIsland.transport_options(),
handler_module: module(),
handler_options: term(),
genserver_options: GenServer.options(),
supervisor_options: [Supervisor.option()],
num_acceptors: pos_integer(),
num_listen_sockets: pos_integer(),
num_connections: non_neg_integer() | :infinity,
max_connections_retry_count: non_neg_integer(),
max_connections_retry_wait: timeout(),
read_timeout: timeout(),
shutdown_timeout: timeout(),
silent_terminate_on_error: boolean()
}
defstruct port: 4000,
transport_module: ThousandIsland.Transports.TCP,
transport_options: [],
handler_module: nil,
handler_options: [],
genserver_options: [],
supervisor_options: [],
num_acceptors: 100,
num_listen_sockets: 1,
num_connections: 16_384,
max_connections_retry_count: 5,
max_connections_retry_wait: 1000,
read_timeout: 60_000,
shutdown_timeout: 15_000,
silent_terminate_on_error: false
@spec new(ThousandIsland.options()) :: t()
def new(opts \\ []) do
config = struct!(__MODULE__, opts)
validate_handler_module!(config)
validate_num_sockets!(config)
validate_reuseport_options!(config)
config
end
defp validate_handler_module!(config) do
if !config.handler_module do
raise("No handler_module defined in server configuration")
end
end
defp validate_num_sockets!(config) do
if config.num_listen_sockets > config.num_acceptors do
raise(
"num_listen_sockets (#{config.num_listen_sockets}) must be less than or equal to num_acceptors (#{config.num_acceptors})"
)
end
end
defp validate_reuseport_options!(config) do
num_listen_sockets = config.num_listen_sockets
transport_options = config.transport_options
has_reuseport = :proplists.get_value(:reuseport, transport_options, false)
has_reuseport_lb = :proplists.get_value(:reuseport_lb, transport_options, false)
unless num_listen_sockets <= 1 or has_reuseport or has_reuseport_lb do
raise ArgumentError,
"reuseport: true or reuseport_lb: true must be set in transport_options when using num_listen_sockets > 1"
end
end
end

View File

@@ -0,0 +1,47 @@
defmodule ThousandIsland.ShutdownListener do
@moduledoc false
# Used as part of the `ThousandIsland.Server` supervision tree to facilitate
# stopping the server's listener process early in the shutdown process, in order
# to allow existing connections to drain without accepting new ones
use GenServer
@type state :: %{
optional(:server_pid) => pid(),
optional(:listener_pid) => pid() | nil
}
@doc false
@spec start_link({pid(), any()}) :: :ignore | {:error, any} | {:ok, pid}
def start_link({server_pid, key}) do
GenServer.start_link(__MODULE__, {server_pid, key})
end
@doc false
@impl GenServer
@spec init({pid(), any()}) :: {:ok, state, {:continue, :setup_listener_pid}}
def init({server_pid, key}) do
Process.flag(:trap_exit, true)
ThousandIsland.ProcessLabel.set(:shutdown_listener, key)
{:ok, %{server_pid: server_pid}, {:continue, :setup_listener_pid}}
end
@doc false
@impl GenServer
@spec handle_continue(:setup_listener_pid, state) :: {:noreply, state}
def handle_continue(:setup_listener_pid, %{server_pid: server_pid}) do
listener_pid = ThousandIsland.Server.listener_pid(server_pid)
{:noreply, %{listener_pid: listener_pid}}
end
@doc false
@impl GenServer
@spec terminate(reason, state) :: :ok
when reason: :normal | :shutdown | {:shutdown, term} | term
def terminate(_reason, %{listener_pid: listener_pid}) do
ThousandIsland.Listener.stop(listener_pid)
end
def terminate(_reason, _state), do: :ok
end

View File

@@ -0,0 +1,236 @@
defmodule ThousandIsland.Socket do
@moduledoc """
Encapsulates a client connection's underlying socket, providing a facility to
read, write, and otherwise manipulate a connection from a client.
"""
@enforce_keys [:socket, :transport_module, :read_timeout, :silent_terminate_on_error, :span]
defstruct @enforce_keys
@typedoc "A reference to a socket along with metadata describing how to use it"
@type t :: %__MODULE__{
socket: ThousandIsland.Transport.socket(),
transport_module: module(),
read_timeout: timeout(),
silent_terminate_on_error: boolean(),
span: ThousandIsland.Telemetry.t()
}
@doc """
Creates a new socket struct based on the passed parameters.
This is normally called internally by `ThousandIsland.Handler` and does not need to be
called by implementations which are based on `ThousandIsland.Handler`
"""
@spec new(
ThousandIsland.Transport.socket(),
ThousandIsland.HandlerConfig.t(),
ThousandIsland.Telemetry.t()
) :: t()
def new(raw_socket, handler_config, span) do
%__MODULE__{
socket: raw_socket,
transport_module: handler_config.transport_module,
read_timeout: handler_config.read_timeout,
silent_terminate_on_error: handler_config.silent_terminate_on_error,
span: span
}
end
@doc """
Handshakes the underlying socket if it is required (as in the case of SSL sockets, for example).
This is normally called internally by `ThousandIsland.Handler` and does not need to be
called by implementations which are based on `ThousandIsland.Handler`
"""
@spec handshake(t()) :: ThousandIsland.Transport.on_handshake()
def handshake(%__MODULE__{} = socket) do
case socket.transport_module.handshake(socket.socket) do
{:ok, inner_socket} ->
{:ok, %{socket | socket: inner_socket}}
{:error, reason} = err ->
ThousandIsland.Telemetry.stop_span(socket.span, %{}, %{error: reason})
err
end
end
@doc """
Upgrades the transport of the socket to use the specified transport module, performing any client
handshaking that may be required. The passed options are blindly passed through to the new
transport module.
This is normally called internally by `ThousandIsland.Handler` and does not need to be
called by implementations which are based on `ThousandIsland.Handler`
"""
@spec upgrade(t(), module(), term()) :: ThousandIsland.Transport.on_upgrade()
def upgrade(%__MODULE__{} = socket, module, opts) when is_atom(module) do
case module.upgrade(socket.socket, opts) do
{:ok, updated_socket} ->
{:ok, %{socket | socket: updated_socket, transport_module: module}}
{:error, reason} = err ->
ThousandIsland.Telemetry.stop_span(socket.span, %{}, %{error: reason})
err
end
end
@doc """
Returns available bytes on the given socket. Up to `length` bytes will be
returned (0 can be passed in to get the next 'available' bytes, typically the
next packet). If insufficient bytes are available, the function can wait `timeout`
milliseconds for data to arrive.
"""
@spec recv(t(), non_neg_integer(), timeout() | nil) :: ThousandIsland.Transport.on_recv()
def recv(%__MODULE__{} = socket, length \\ 0, timeout \\ nil) do
case socket.transport_module.recv(socket.socket, length, timeout || socket.read_timeout) do
{:ok, data} = ok ->
ThousandIsland.Telemetry.untimed_span_event(socket.span, :recv, %{data: data})
ok
{:error, reason} = err ->
ThousandIsland.Telemetry.span_event(socket.span, :recv_error, %{error: reason})
err
end
end
@doc """
Sends the given data (specified as a binary or an IO list) on the given socket.
"""
@spec send(t(), iodata()) :: ThousandIsland.Transport.on_send()
def send(%__MODULE__{} = socket, data) do
case socket.transport_module.send(socket.socket, data) do
:ok ->
ThousandIsland.Telemetry.untimed_span_event(socket.span, :send, %{data: data})
:ok
{:error, reason} = err ->
ThousandIsland.Telemetry.span_event(socket.span, :send_error, %{data: data, error: reason})
err
end
end
@doc """
Sends the contents of the given file based on the provided offset & length
"""
@spec sendfile(t(), String.t(), non_neg_integer(), non_neg_integer()) ::
ThousandIsland.Transport.on_sendfile()
def sendfile(%__MODULE__{} = socket, filename, offset, length) do
case socket.transport_module.sendfile(socket.socket, filename, offset, length) do
{:ok, bytes_written} = ok ->
measurements = %{filename: filename, offset: offset, bytes_written: bytes_written}
ThousandIsland.Telemetry.untimed_span_event(socket.span, :sendfile, measurements)
ok
{:error, reason} = err ->
measurements = %{filename: filename, offset: offset, length: length, error: reason}
ThousandIsland.Telemetry.span_event(socket.span, :sendfile_error, measurements)
err
end
end
@doc """
Shuts down the socket in the given direction.
"""
@spec shutdown(t(), ThousandIsland.Transport.way()) :: ThousandIsland.Transport.on_shutdown()
def shutdown(%__MODULE__{} = socket, way) do
ThousandIsland.Telemetry.span_event(socket.span, :socket_shutdown, %{way: way})
socket.transport_module.shutdown(socket.socket, way)
end
@doc """
Closes the given socket. Note that a socket is automatically closed when the handler
process which owns it terminates
"""
@spec close(t()) :: ThousandIsland.Transport.on_close()
def close(%__MODULE__{} = socket) do
socket.transport_module.close(socket.socket)
end
@doc """
Gets the given flags on the socket
Errors are usually from :inet.posix(), however, SSL module defines return type as any()
"""
@spec getopts(t(), ThousandIsland.Transport.socket_get_options()) ::
ThousandIsland.Transport.on_getopts()
def getopts(%__MODULE__{} = socket, options) do
socket.transport_module.getopts(socket.socket, options)
end
@doc """
Sets the given flags on the socket
Errors are usually from :inet.posix(), however, SSL module defines return type as any()
"""
@spec setopts(t(), ThousandIsland.Transport.socket_set_options()) ::
ThousandIsland.Transport.on_setopts()
def setopts(%__MODULE__{} = socket, options) do
socket.transport_module.setopts(socket.socket, options)
end
@doc """
Returns information in the form of `t:ThousandIsland.Transport.socket_info()` about the local end of the socket.
"""
@spec sockname(t()) :: ThousandIsland.Transport.on_sockname()
def sockname(%__MODULE__{} = socket) do
socket.transport_module.sockname(socket.socket)
end
@doc """
Returns information in the form of `t:ThousandIsland.Transport.socket_info()` about the remote end of the socket.
"""
@spec peername(t()) :: ThousandIsland.Transport.on_peername()
def peername(%__MODULE__{} = socket) do
socket.transport_module.peername(socket.socket)
end
@doc """
Returns information in the form of `t:public_key.der_encoded()` about the peer certificate of the socket.
"""
@spec peercert(t()) :: ThousandIsland.Transport.on_peercert()
def peercert(%__MODULE__{} = socket) do
socket.transport_module.peercert(socket.socket)
end
@doc """
Returns whether or not this protocol is secure.
"""
@spec secure?(t()) :: boolean()
def secure?(%__MODULE__{} = socket) do
socket.transport_module.secure?()
end
@doc """
Returns statistics about the connection.
"""
@spec getstat(t()) :: ThousandIsland.Transport.socket_stats()
def getstat(%__MODULE__{} = socket) do
socket.transport_module.getstat(socket.socket)
end
@doc """
Returns information about the protocol negotiated during transport handshaking (if any).
"""
@spec negotiated_protocol(t()) :: ThousandIsland.Transport.on_negotiated_protocol()
def negotiated_protocol(%__MODULE__{} = socket) do
socket.transport_module.negotiated_protocol(socket.socket)
end
@doc """
Returns information about the SSL connection info, if transport is SSL.
"""
@spec connection_information(t()) :: ThousandIsland.Transport.on_connection_information()
def connection_information(%__MODULE__{} = socket) do
socket.transport_module.connection_information(socket.socket)
end
@doc """
Returns the telemetry span representing the lifetime of this socket
"""
@spec telemetry_span(t()) :: ThousandIsland.Telemetry.t()
def telemetry_span(%__MODULE__{} = socket) do
socket.span
end
end

View File

@@ -0,0 +1,400 @@
defmodule ThousandIsland.Telemetry do
@moduledoc """
The following telemetry spans are emitted by thousand_island
## `[:thousand_island, :listener, *]`
Represents a Thousand Island server listening to a port
This span is started by the following event:
* `[:thousand_island, :listener, :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
* `local_address`: The IP address that the listener is bound to
* `local_port`: The port that the listener is bound to
* `transport_module`: The transport module in use
* `transport_options`: Options passed to the transport module at startup
This span is ended by the following event:
* `[:thousand_island, :listener, :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
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `local_address`: The IP address that the listener is bound to
* `local_port`: The port that the listener is bound to
* `transport_module`: The transport module in use
* `transport_options`: Options passed to the transport module at startup
## `[:thousand_island, :acceptor, *]`
Represents a Thousand Island acceptor process listening for connections
This span is started by the following event:
* `[:thousand_island, :acceptor, :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
* `parent_telemetry_span_context`: The span context of the `:listener` which created this acceptor
This span is ended by the following event:
* `[:thousand_island, :acceptor, :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
* `connections`: The number of client requests that the acceptor handled
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `parent_telemetry_span_context`: The span context of the `:listener` which created this acceptor
* `error`: The error that caused the span to end, if it ended in error
The following events may be emitted within this span:
* `[:thousand_island, :acceptor, :spawn_error]`
Thousand Island was unable to spawn a process to handle a connection. This occurs when too
many connections are in progress; you may want to look at increasing the `num_connections`
configuration parameter
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
* `[:thousand_island, :acceptor, :econnaborted]`
Thousand Island was unable to spawn a process to handle a connection since the remote end
closed before we could accept it. This usually occurs when it takes too long for your server
to start processing a connection; you may want to look at tuning OS-level TCP parameters or
adding more server capacity.
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
## `[:thousand_island, :connection, *]`
Represents Thousand Island handling a specific client request
This span is started by the following event:
* `[:thousand_island, :connection, :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
* `parent_telemetry_span_context`: The span context of the `:acceptor` span which accepted
this connection
* `remote_address`: The IP address of the connected client
* `remote_port`: The port of the connected client
This span is ended by the following event:
* `[:thousand_island, :connection, :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
* `send_oct`: The number of octets sent on the connection
* `send_cnt`: The number of packets sent on the connection
* `recv_oct`: The number of octets received on the connection
* `recv_cnt`: The number of packets received on the connection
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `parent_telemetry_span_context`: The span context of the `:acceptor` span which accepted
this connection
* `remote_address`: The IP address of the connected client
* `remote_port`: The port of the connected client
* `error`: The error that caused the span to end, if it ended in error
The following events may be emitted within this span:
* `[:thousand_island, :connection, :ready]`
Thousand Island has completed setting up the client connection
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
* `[:thousand_island, :connection, :async_recv]`
Thousand Island has asynchronously received data from the client
This event contains the following measurements:
* `data`: The data received from the client
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :recv]`
Thousand Island has synchronously received data from the client
This event contains the following measurements:
* `data`: The data received from the client
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :recv_error]`
Thousand Island encountered an error reading data from the client
This event contains the following measurements:
* `error`: A description of the error
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :send]`
Thousand Island has sent data to the client
This event contains the following measurements:
* `data`: The data sent to the client
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :send_error]`
Thousand Island encountered an error sending data to the client
This event contains the following measurements:
* `data`: The data that was being sent to the client
* `error`: A description of the error
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :sendfile]`
Thousand Island has sent a file to the client
This event contains the following measurements:
* `filename`: The filename containing data sent to the client
* `offset`: The offset (in bytes) within the file sending started from
* `bytes_written`: The number of bytes written
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :sendfile_error]`
Thousand Island encountered an error sending a file to the client
This event contains the following measurements:
* `filename`: The filename containing data that was being sent to the client
* `offset`: The offset (in bytes) within the file where sending started from
* `length`: The number of bytes that were attempted to send
* `error`: A description of the error
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
* `[:thousand_island, :connection, :socket_shutdown]`
Thousand Island has shutdown the client connection
This event contains the following measurements:
* `monotonic_time`: The time of this event, in `:native` units
* `way`: The direction in which the socket was shut down
This event contains the following metadata:
* `telemetry_span_context`: A unique identifier for this span
"""
@enforce_keys [
:span_name,
:telemetry_span_context,
:start_time,
:start_metadata,
:handler,
:span_metadata
]
defstruct @enforce_keys
@type t :: %__MODULE__{
span_name: span_name(),
telemetry_span_context: reference(),
start_time: integer(),
start_metadata: metadata(),
handler: module(),
span_metadata: metadata()
}
@type span_name :: :listener | :acceptor | :connection
@type metadata :: :telemetry.event_metadata()
@typedoc false
@type measurements :: :telemetry.event_measurements()
@typedoc false
@type event_name ::
:ready
| :spawn_error
| :econnaborted
| :recv_error
| :send_error
| :sendfile_error
| :socket_shutdown
@typedoc false
@type untimed_event_name ::
:async_recv
| :stop
| :recv
| :send
| :sendfile
@app_name :thousand_island
@doc false
@spec start_span(span_name(), measurements(), 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)
handler = metadata.handler
# Pre-build the metadata that will be used for all events in this span
span_metadata = %{
telemetry_span_context: telemetry_span_context,
handler: handler
}
%__MODULE__{
span_name: span_name,
telemetry_span_context: telemetry_span_context,
start_time: measurements[:monotonic_time],
start_metadata: metadata,
handler: handler,
span_metadata: span_metadata
}
end
@doc false
@spec start_child_span(t(), span_name(), measurements(), metadata()) :: t()
def start_child_span(parent_span, span_name, measurements \\ %{}, metadata \\ %{}) do
metadata =
Map.merge(metadata, %{
parent_telemetry_span_context: parent_span.telemetry_span_context,
handler: parent_span.handler
})
start_span(span_name, measurements, metadata)
end
@doc false
@spec stop_span(t(), measurements(), metadata()) :: :ok
def stop_span(span, measurements \\ %{}, metadata \\ %{}) do
monotonic_time = measurements[:monotonic_time] || monotonic_time()
measurements =
Map.merge(measurements, %{
monotonic_time: monotonic_time,
duration: monotonic_time - span.start_time
})
metadata = Map.merge(span.start_metadata, metadata)
untimed_span_event(span, :stop, measurements, metadata)
end
@doc false
@spec span_event(t(), event_name(), measurements(), 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(), event_name() | untimed_event_name(), measurements(), metadata()) ::
:ok
def untimed_span_event(span, name, measurements \\ %{}, metadata \\ %{}) do
metadata = Map.merge(metadata, span.span_metadata)
event([span.span_name, name], measurements, metadata)
end
@spec monotonic_time() :: integer
defdelegate monotonic_time, to: System
defp event(suffix, measurements, metadata) do
:telemetry.execute([@app_name | suffix], measurements, metadata)
end
end

View File

@@ -0,0 +1,220 @@
defmodule ThousandIsland.Transport do
@moduledoc """
This module describes the behaviour required for Thousand Island to interact
with low-level sockets. It is largely internal to Thousand Island, however users
are free to implement their own versions of this behaviour backed by whatever
underlying transport they choose. Such a module can be used in Thousand Island
by passing its name as the `transport_module` option when starting up a server,
as described in `ThousandIsland`.
"""
@typedoc "A listener socket used to wait for connections"
@type listener_socket() :: :inet.socket() | :ssl.sslsocket()
@typedoc "A listener socket options"
@type listen_options() ::
[:inet.inet_backend() | :gen_tcp.listen_option()] | [:ssl.tls_server_option()]
@typedoc "A socket representing a client connection"
@type socket() :: :inet.socket() | :ssl.sslsocket()
@typedoc "Information about an endpoint, either remote ('peer') or local"
@type socket_info() ::
{:inet.ip_address(), :inet.port_number()} | :inet.returned_non_ip_address()
@typedoc "A socket address"
@type address ::
:inet.ip_address()
| :inet.local_address()
| {:local, binary()}
| :unspec
| {:undefined, any()}
@typedoc "Connection statistics for a given socket"
@type socket_stats() :: {:ok, [{:inet.stat_option(), integer()}]} | {:error, :inet.posix()}
@typedoc "Options which can be set on a socket via setopts/2 (or returned from getopts/1)"
@type socket_get_options() :: [:inet.socket_getopt()]
@typedoc "Options which can be set on a socket via setopts/2 (or returned from getopts/1)"
@type socket_set_options() :: [:inet.socket_setopt()]
@typedoc "The direction in which to shutdown a connection in advance of closing it"
@type way() :: :read | :write | :read_write
@typedoc "The return value from a listen/2 call"
@type on_listen() ::
{:ok, listener_socket()} | {:error, :system_limit} | {:error, :inet.posix()}
@typedoc "The return value from an accept/1 call"
@type on_accept() :: {:ok, socket()} | {:error, on_accept_tcp_error() | on_accept_ssl_error()}
@type on_accept_tcp_error() :: :closed | :system_limit | :inet.posix()
@type on_accept_ssl_error() :: :closed | :timeout | :ssl.error_alert()
@typedoc "The return value from a controlling_process/2 call"
@type on_controlling_process() :: :ok | {:error, :closed | :not_owner | :badarg | :inet.posix()}
@typedoc "The return value from a handshake/1 call"
@type on_handshake() :: {:ok, socket()} | {:error, on_handshake_ssl_error()}
@type on_handshake_ssl_error() :: :closed | :timeout | :ssl.error_alert()
@typedoc "The return value from a upgrade/2 call"
@type on_upgrade() :: {:ok, socket()} | {:error, term()}
@typedoc "The return value from a shutdown/2 call"
@type on_shutdown() :: :ok | {:error, :inet.posix()}
@typedoc "The return value from a close/1 call"
@type on_close() :: :ok | {:error, any()}
@typedoc "The return value from a recv/3 call"
@type on_recv() :: {:ok, binary()} | {:error, :closed | :timeout | :inet.posix()}
@typedoc "The return value from a send/2 call"
@type on_send() :: :ok | {:error, :closed | {:timeout, rest_data :: binary()} | :inet.posix()}
@typedoc "The return value from a sendfile/4 call"
@type on_sendfile() ::
{:ok, non_neg_integer()}
| {:error, :inet.posix() | :closed | :badarg | :not_owner | :eof}
@typedoc "The return value from a getopts/2 call"
@type on_getopts() :: {:ok, [:inet.socket_optval()]} | {:error, :inet.posix()}
@typedoc "The return value from a setopts/2 call"
@type on_setopts() :: :ok | {:error, :inet.posix()}
@typedoc "The return value from a sockname/1 call"
@type on_sockname() :: {:ok, socket_info()} | {:error, :inet.posix()}
@typedoc "The return value from a peername/1 call"
@type on_peername() :: {:ok, socket_info()} | {:error, :inet.posix()}
@typedoc "The return value from a peercert/1 call"
@type on_peercert() :: {:ok, :public_key.der_encoded()} | {:error, reason :: any()}
@typedoc "The return value from a connection_information/1 call"
@type on_connection_information() :: {:ok, :ssl.connection_info()} | {:error, reason :: any()}
@typedoc "The return value from a negotiated_protocol/1 call"
@type on_negotiated_protocol() ::
{:ok, binary()} | {:error, :protocol_not_negotiated | :closed}
@doc """
Create and return a listener socket bound to the given port and configured per
the provided options.
"""
@callback listen(:inet.port_number(), listen_options()) ::
{:ok, listener_socket()} | {:error, any()}
@doc """
Wait for a client connection on the given listener socket. This call blocks until
such a connection arrives, or an error occurs (such as the listener socket being
closed).
"""
@callback accept(listener_socket()) :: on_accept()
@doc """
Performs an initial handshake on a new client connection (such as that done
when negotiating an SSL connection). Transports which do not have such a
handshake can simply pass the socket through unchanged.
"""
@callback handshake(socket()) :: on_handshake()
@doc """
Performs an upgrade of an existing client connection (for example upgrading
an already-established connection to SSL). Transports which do not support upgrading can return
`{:error, :unsupported_upgrade}`.
"""
@callback upgrade(socket(), term()) :: on_upgrade()
@doc """
Transfers ownership of the given socket to the given process. This will always
be called by the process which currently owns the socket.
"""
@callback controlling_process(socket(), pid()) :: on_controlling_process()
@doc """
Returns available bytes on the given socket. Up to `num_bytes` bytes will be
returned (0 can be passed in to get the next 'available' bytes, typically the
next packet). If insufficient bytes are available, the function can wait `timeout`
milliseconds for data to arrive.
"""
@callback recv(socket(), num_bytes :: non_neg_integer(), timeout :: timeout()) :: on_recv()
@doc """
Sends the given data (specified as a binary or an IO list) on the given socket.
"""
@callback send(socket(), data :: iodata()) :: on_send()
@doc """
Sends the contents of the given file based on the provided offset & length
"""
@callback sendfile(
socket(),
filename :: String.t(),
offset :: non_neg_integer(),
length :: non_neg_integer()
) :: on_sendfile()
@doc """
Gets the given options on the socket.
"""
@callback getopts(socket(), socket_get_options()) :: on_getopts()
@doc """
Sets the given options on the socket. Should disallow setting of options which
are not compatible with Thousand Island
"""
@callback setopts(socket(), socket_set_options()) :: on_setopts()
@doc """
Shuts down the socket in the given direction.
"""
@callback shutdown(socket(), way()) :: on_shutdown()
@doc """
Closes the given socket.
"""
@callback close(socket() | listener_socket()) :: on_close()
@doc """
Returns information in the form of `t:socket_info()` about the local end of the socket.
"""
@callback sockname(socket() | listener_socket()) :: on_sockname()
@doc """
Returns information in the form of `t:socket_info()` about the remote end of the socket.
"""
@callback peername(socket()) :: on_peername()
@doc """
Returns the peer certificate for the given socket in the form of `t:public_key.der_encoded()`.
If the socket is not secure, `{:error, :not_secure}` is returned.
"""
@callback peercert(socket()) :: on_peercert()
@doc """
Returns whether or not this protocol is secure.
"""
@callback secure?() :: boolean()
@doc """
Returns stats about the connection on the socket.
"""
@callback getstat(socket()) :: socket_stats()
@doc """
Returns the protocol negotiated as part of handshaking. Most typically this is via TLS'
ALPN or NPN extensions. If the underlying transport does not support protocol negotiation
(or if one was not negotiated), `{:error, :protocol_not_negotiated}` is returned
"""
@callback negotiated_protocol(socket()) :: on_negotiated_protocol()
@doc """
Returns the SSL connection_info for the given socket. If the socket is not secure,
`{:error, :not_secure}` is returned.
"""
@callback connection_information(socket()) :: on_connection_information()
end

View File

@@ -0,0 +1,221 @@
defmodule ThousandIsland.Transports.SSL do
@moduledoc """
Defines a `ThousandIsland.Transport` implementation based on TCP SSL sockets
as provided by Erlang's `:ssl` module. For the most part, users of Thousand
Island will only ever need to deal with this module via `transport_options`
passed to `ThousandIsland` at startup time. A complete list of such options
is defined via the `t::ssl.tls_server_option/0` type. This list can be somewhat
difficult to decipher; by far the most common values to pass to this transport
are the following:
* `keyfile`: The path to a PEM encoded key to use for SSL
* `certfile`: The path to a PEM encoded cert to use for SSL
* `ip`: The IP to listen on. 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
* `: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`.
Unless overridden, this module uses the following default options:
```elixir
backlog: 1024,
nodelay: true,
send_timeout: 30_000,
send_timeout_close: true,
reuseaddr: true
```
The following options are required for the proper operation of Thousand Island
and cannot be overridden:
```elixir
mode: :binary,
active: false
```
"""
@type options() :: [:ssl.tls_server_option()]
@type listener_socket() :: :ssl.sslsocket()
@type socket() :: :ssl.sslsocket()
@behaviour ThousandIsland.Transport
@hardcoded_options [mode: :binary, active: false]
# Default chunk size: 8MB - balances memory usage vs syscall overhead
@sendfile_chunk_size 8 * 1024 * 1024
@impl ThousandIsland.Transport
@spec listen(:inet.port_number(), [:ssl.tls_server_option()]) ::
ThousandIsland.Transport.on_listen()
def listen(port, user_options) do
default_options = [
backlog: 1024,
nodelay: true,
send_timeout: 30_000,
send_timeout_close: true,
reuseaddr: true
]
# We can't use Keyword functions here because :ssl accepts non-keyword style options
resolved_options =
Enum.uniq_by(
@hardcoded_options ++ user_options ++ default_options,
fn
{key, _} when is_atom(key) -> key
key when is_atom(key) -> key
end
)
if not Enum.any?(
[:certs_keys, :keyfile, :key, :sni_hosts, :sni_fun],
&:proplists.is_defined(&1, resolved_options)
) do
raise "transport_options must include one of keyfile, key, sni_hosts or sni_fun"
end
if not Enum.any?(
[:certs_keys, :certfile, :cert, :sni_hosts, :sni_fun],
&:proplists.is_defined(&1, resolved_options)
) do
raise "transport_options must include one of certfile, cert, sni_hosts or sni_fun"
end
:ssl.listen(port, resolved_options)
end
@impl ThousandIsland.Transport
@spec accept(listener_socket()) :: ThousandIsland.Transport.on_accept()
defdelegate accept(listener_socket), to: :ssl, as: :transport_accept
@impl ThousandIsland.Transport
@spec handshake(socket()) :: ThousandIsland.Transport.on_handshake()
def handshake(socket) do
case :ssl.handshake(socket) do
{:ok, socket, _protocol_extensions} -> {:ok, socket}
other -> other
end
end
@impl ThousandIsland.Transport
@spec upgrade(socket(), options()) :: ThousandIsland.Transport.on_upgrade()
def upgrade(socket, opts) do
case :ssl.handshake(socket, opts) do
{:ok, socket, _protocol_extensions} -> {:ok, socket}
other -> other
end
end
@impl ThousandIsland.Transport
@spec controlling_process(socket(), pid()) :: ThousandIsland.Transport.on_controlling_process()
defdelegate controlling_process(socket, pid), to: :ssl
@impl ThousandIsland.Transport
@spec recv(socket(), non_neg_integer(), timeout()) :: ThousandIsland.Transport.on_recv()
defdelegate recv(socket, length, timeout), to: :ssl
@impl ThousandIsland.Transport
@spec send(socket(), iodata()) :: ThousandIsland.Transport.on_send()
defdelegate send(socket, data), to: :ssl
@impl ThousandIsland.Transport
@spec sendfile(
socket(),
filename :: String.t(),
offset :: non_neg_integer(),
length :: non_neg_integer()
) :: ThousandIsland.Transport.on_sendfile()
def sendfile(socket, filename, offset, length) do
# We can't use :file.sendfile here since it works on clear sockets, not ssl sockets.
# Build our own version with chunking for large files.
case :file.open(filename, [:read, :raw, :binary]) do
{:ok, fd} ->
try do
sendfile_loop(socket, fd, offset, length, 0)
after
:file.close(fd)
end
{:error, reason} ->
{:error, reason}
end
end
defp sendfile_loop(_socket, _fd, _offset, sent, sent) when 0 != sent do
{:ok, sent}
end
defp sendfile_loop(socket, fd, offset, length, sent) do
with read_size <- chunk_size(length, sent, @sendfile_chunk_size),
{:ok, data} <- :file.pread(fd, offset, read_size),
:ok <- :ssl.send(socket, data) do
now_sent = byte_size(data)
sendfile_loop(socket, fd, offset + now_sent, length, sent + now_sent)
else
:eof ->
{:ok, sent}
{:error, reason} ->
{:error, reason}
end
end
defp chunk_size(0, _sent, chunk_size), do: chunk_size
defp chunk_size(length, sent, chunk), do: min(length - sent, chunk)
@impl ThousandIsland.Transport
@spec getopts(socket(), ThousandIsland.Transport.socket_get_options()) ::
ThousandIsland.Transport.on_getopts()
defdelegate getopts(socket, options), to: :ssl
@impl ThousandIsland.Transport
@spec setopts(socket(), ThousandIsland.Transport.socket_set_options()) ::
ThousandIsland.Transport.on_setopts()
defdelegate setopts(socket, options), to: :ssl
@impl ThousandIsland.Transport
@spec shutdown(socket(), ThousandIsland.Transport.way()) ::
ThousandIsland.Transport.on_shutdown()
defdelegate shutdown(socket, way), to: :ssl
@impl ThousandIsland.Transport
@spec close(socket() | listener_socket()) :: ThousandIsland.Transport.on_close()
defdelegate close(socket), to: :ssl
# :ssl.sockname/1's typespec is incorrect
@dialyzer {:no_match, sockname: 1}
@impl ThousandIsland.Transport
@spec sockname(socket() | listener_socket()) :: ThousandIsland.Transport.on_sockname()
defdelegate sockname(socket), to: :ssl
# :ssl.peername/1's typespec is incorrect
@dialyzer {:no_match, peername: 1}
@impl ThousandIsland.Transport
@spec peername(socket()) :: ThousandIsland.Transport.on_peername()
defdelegate peername(socket), to: :ssl
@impl ThousandIsland.Transport
@spec peercert(socket()) :: ThousandIsland.Transport.on_peercert()
defdelegate peercert(socket), to: :ssl
@impl ThousandIsland.Transport
@spec secure?() :: true
def secure?, do: true
@impl ThousandIsland.Transport
@spec getstat(socket()) :: ThousandIsland.Transport.socket_stats()
defdelegate getstat(socket), to: :ssl
@impl ThousandIsland.Transport
@spec negotiated_protocol(socket()) :: ThousandIsland.Transport.on_negotiated_protocol()
defdelegate negotiated_protocol(socket), to: :ssl
@impl ThousandIsland.Transport
@spec connection_information(socket()) :: ThousandIsland.Transport.on_connection_information()
defdelegate connection_information(socket), to: :ssl
end

View File

@@ -0,0 +1,169 @@
defmodule ThousandIsland.Transports.TCP do
@moduledoc """
Defines a `ThousandIsland.Transport` implementation based on clear TCP sockets
as provided by Erlang's `:gen_tcp` module. For the most part, users of Thousand
Island will only ever need to deal with this module via `transport_options`
passed to `ThousandIsland` at startup time. A complete list of such options
is defined via the `t::gen_tcp.listen_option/0` type. This list can be somewhat
difficult to decipher; by far the most common value to pass to this transport
is the following:
* `ip`: The IP to listen on. 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
* `:any` for all interfaces (i.e.: `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`
Unless overridden, this module uses the following default options:
```elixir
backlog: 1024,
nodelay: true,
send_timeout: 30_000,
send_timeout_close: true,
reuseaddr: true
```
The following options are required for the proper operation of Thousand Island
and cannot be overridden:
```elixir
mode: :binary,
active: false
```
"""
@type options() :: [:gen_tcp.listen_option()]
@type listener_socket() :: :inet.socket()
@type socket() :: :inet.socket()
@behaviour ThousandIsland.Transport
@hardcoded_options [mode: :binary, active: false]
@impl ThousandIsland.Transport
@spec listen(:inet.port_number(), [:inet.inet_backend() | :gen_tcp.listen_option()]) ::
ThousandIsland.Transport.on_listen()
def listen(port, user_options) do
default_options = [
backlog: 1024,
nodelay: true,
send_timeout: 30_000,
send_timeout_close: true,
reuseaddr: true
]
# We can't use Keyword functions here because :gen_tcp accepts non-keyword style options
resolved_options =
Enum.uniq_by(
@hardcoded_options ++ user_options ++ default_options,
fn
{key, _} when is_atom(key) -> key
key when is_atom(key) -> key
end
)
# `inet_backend`, if present, needs to be the first option
sorted_options =
Enum.sort(resolved_options, fn
_, {:inet_backend, _} -> false
_, _ -> true
end)
:gen_tcp.listen(port, sorted_options)
end
@impl ThousandIsland.Transport
@spec accept(listener_socket()) :: ThousandIsland.Transport.on_accept()
defdelegate accept(listener_socket), to: :gen_tcp
@impl ThousandIsland.Transport
@spec handshake(socket()) :: ThousandIsland.Transport.on_handshake()
def handshake(socket), do: {:ok, socket}
@impl ThousandIsland.Transport
@spec upgrade(socket(), options()) :: ThousandIsland.Transport.on_upgrade()
def upgrade(_, _), do: {:error, :unsupported_upgrade}
@impl ThousandIsland.Transport
@spec controlling_process(socket(), pid()) :: ThousandIsland.Transport.on_controlling_process()
defdelegate controlling_process(socket, pid), to: :gen_tcp
@impl ThousandIsland.Transport
@spec recv(socket(), non_neg_integer(), timeout()) :: ThousandIsland.Transport.on_recv()
defdelegate recv(socket, length, timeout), to: :gen_tcp
@impl ThousandIsland.Transport
@spec send(socket(), iodata()) :: ThousandIsland.Transport.on_send()
defdelegate send(socket, data), to: :gen_tcp
@impl ThousandIsland.Transport
@spec sendfile(
socket(),
filename :: String.t(),
offset :: non_neg_integer(),
length :: non_neg_integer()
) :: ThousandIsland.Transport.on_sendfile()
def sendfile(socket, filename, offset, length) do
case :file.open(filename, [:read, :raw, :binary]) do
{:ok, fd} ->
try do
:file.sendfile(fd, socket, offset, length, [])
after
:file.close(fd)
end
{:error, reason} ->
{:error, reason}
end
end
@impl ThousandIsland.Transport
@spec getopts(socket(), ThousandIsland.Transport.socket_get_options()) ::
ThousandIsland.Transport.on_getopts()
defdelegate getopts(socket, options), to: :inet
@impl ThousandIsland.Transport
@spec setopts(socket(), ThousandIsland.Transport.socket_set_options()) ::
ThousandIsland.Transport.on_setopts()
defdelegate setopts(socket, options), to: :inet
@impl ThousandIsland.Transport
@spec shutdown(socket(), ThousandIsland.Transport.way()) ::
ThousandIsland.Transport.on_shutdown()
defdelegate shutdown(socket, way), to: :gen_tcp
@impl ThousandIsland.Transport
@spec close(socket() | listener_socket()) :: :ok
defdelegate close(socket), to: :gen_tcp
@impl ThousandIsland.Transport
@spec sockname(socket() | listener_socket()) :: ThousandIsland.Transport.on_sockname()
defdelegate sockname(socket), to: :inet
@impl ThousandIsland.Transport
@spec peername(socket()) :: ThousandIsland.Transport.on_peername()
defdelegate peername(socket), to: :inet
@impl ThousandIsland.Transport
@spec peercert(socket()) :: ThousandIsland.Transport.on_peercert()
def peercert(_socket), do: {:error, :not_secure}
@impl ThousandIsland.Transport
@spec secure?() :: false
def secure?, do: false
@impl ThousandIsland.Transport
@spec getstat(socket()) :: ThousandIsland.Transport.socket_stats()
defdelegate getstat(socket), to: :inet
@impl ThousandIsland.Transport
@spec negotiated_protocol(socket()) :: ThousandIsland.Transport.on_negotiated_protocol()
def negotiated_protocol(_socket), do: {:error, :protocol_not_negotiated}
@impl ThousandIsland.Transport
@spec connection_information(socket()) :: ThousandIsland.Transport.on_connection_information()
def connection_information(_socket), do: {:error, :not_secure}
end

View File

@@ -0,0 +1,80 @@
defmodule ThousandIsland.MixProject do
use Mix.Project
def project do
[
app: :thousand_island,
version: "1.4.3",
elixir: "~> 1.13",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
dialyzer: dialyzer(),
name: "Thousand Island",
description: "A simple & modern pure Elixir socket server",
source_url: "https://github.com/mtrudel/thousand_island",
package: [
maintainers: ["Mat Trudel"],
licenses: ["MIT"],
links: %{
"GitHub" => "https://github.com/mtrudel/thousand_island",
"Changelog" => "https://hexdocs.pm/thousand_island/changelog.html"
},
files: ["lib", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"]
],
docs: docs()
]
end
def application do
[extra_applications: [:logger, :ssl]]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp deps() do
[
{:telemetry, "~> 0.4 or ~> 1.0"},
{:machete, ">= 0.0.0", only: [:dev, :test]},
{:ex_doc, "~> 0.25", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false},
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}
]
end
defp dialyzer do
[
plt_core_path: "priv/plts",
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_add_deps: :apps_direct,
plt_add_apps: [:public_key],
flags: [
"-Werror_handling",
"-Wextra_return",
"-Wmissing_return",
"-Wunknown",
"-Wunmatched_returns",
"-Wunderspecs"
]
]
end
defp docs do
[
main: "ThousandIsland",
logo: "assets/ex_doc_logo.png",
extras: ["CHANGELOG.md"],
groups_for_extras: [
Changelog: ~r/CHANGELOG\.md/
],
groups_for_modules: [
Transport: [
ThousandIsland.Transport,
ThousandIsland.Transports.TCP,
ThousandIsland.Transports.SSL
]
]
]
end
end