An OTP-friendly embedded network stack powered by
smoltcp.
Each stack is an independent, supervised native network namespace. Stacks exchange complete raw IPv4 or IPv6 packets with a caller-provided link process.
Links
A link carries a stack's packets over an application-defined transport. It
receives outbound batches as {:smol_stack, link_ref, :egress, packets} and
hands inbound packets back with ingress/2. Each side watches the other. The
stack monitors its link and applies its :link_down policy when the link
exits. A link calls monitor/1 to receive an ordinary :DOWN message when
its stack stops, whether through stop_stack/1 or a crash, so it can exit
instead of running a transport in front of a stack that is gone.
A link cannot refuse a batch once the stack has sent it. A link with a
bounded queue can instead start its stack with :egress_credit and grant
more with grant_egress/3 as it forwards packets. The stack then never sends
more than the link has granted, and holds the rest back in its sockets.
TCP endpoints and errors
Low-level TCP endpoints use explicit :socket-style maps:
%{family: :inet, addr: {192, 0, 2, 2}, port: 443}
%{family: :inet6, addr: {0xfd00, 0, 0, 0, 0, 0, 0, 2}, port: 443}flowinfo may be omitted or set to zero. A link-local fe80::/10 address
requires a positive integer scope_id identifying its raw-IP link zone;
global addresses require scope_id: 0 (the default).
Stable validation and bind errors are :unsupported_family,
:unsupported_socket, :invalid_options, :invalid_address,
:invalid_port, :invalid_backlog, :invalid_data, :invalid_length,
:invalid_timeout, :message_too_large,
:invalid_how, :scope_required, :invalid_scope, :address_in_use,
:address_not_available, and :ephemeral_ports_exhausted. Connection and
stream lifecycle errors are
:network_unreachable, :connection_refused, :connection_reset,
:connection_timeout, :already_connected, :not_bound,
:not_connected, :busy, :closed, :invalid_socket, and
:invalid_socket_state.
An established connection fails with :connection_timeout when its peer
answers nothing for 924.6 s while it has data or a FIN outstanding, or
data a zero window holds back, as Linux gives up by default; or when its
keep-alive probes go unanswered (see setopt/3). A pending recv/3 or
send/3 fails with it at once, and later calls return it too. An idle
connection, with nothing outstanding, never times out without keep-alive.
The timeout is fixed.
Summary
Functions
Accepts a TCP child, waiting indefinitely by default.
Accepts with a finite, infinite, or nonblocking timeout.
Binds a TCP or UDP socket to an endpoint of its family.
Cancels the exact pending nonblocking operation identified by select_info.
Closes a socket and permanently invalidates its public handle.
Connects a TCP or UDP socket, waiting indefinitely by default.
Connects a TCP or UDP socket with a finite, infinite, or nonblocking timeout.
Returns a TCP socket option set with setopt/3.
Grants a stack started with :egress_credit more egress.
Hands raw IPv4 or IPv6 packets from the stack's link feeder to the stack.
Turns a bound TCP socket into a reusable bounded listener.
Monitors a stack from the calling process.
Opens a bounded low-level TCP stream or UDP datagram socket on stack.
Returns the peer endpoint for a connected TCP or UDP socket.
Receives TCP stream data, waiting indefinitely by default.
Receives TCP stream data with a finite, infinite, or nonblocking timeout.
Receives one UDP datagram, waiting indefinitely by default.
Receives one UDP datagram with a finite, infinite, or nonblocking timeout.
Sends a complete TCP byte stream, waiting indefinitely by default.
Sends TCP stream data with a finite, infinite, or nonblocking timeout.
Sends one complete UDP datagram, waiting indefinitely by default.
Sends one complete UDP datagram with a finite, infinite, or nonblocking timeout.
Sets a socket option on a TCP socket.
Shuts down the read half, write half, or both halves of a TCP socket.
Returns the bound endpoint for a TCP or UDP socket.
Returns ingress, link, timer, and native stack metrics.
Starts a raw-IP network stack.
Stops a stack and its complete runtime bundle.
Functions
@spec accept(SmolNet.Socket.t()) :: {:ok, SmolNet.Socket.t()} | {:error, atom()}
Accepts a TCP child, waiting indefinitely by default.
@spec accept(SmolNet.Socket.t(), :nowait | timeout()) :: {:ok, SmolNet.Socket.t()} | {:select, :socket.select_info()} | {:error, atom()}
Accepts with a finite, infinite, or nonblocking timeout.
:nowait returns a read-direction {:select, select_info} retry hint. Each
accepted child has a fresh stable identity and is independent of the
listener after it is returned.
@spec bind( SmolNet.Socket.t(), SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6() ) :: :ok | {:error, atom()}
Binds a TCP or UDP socket to an endpoint of its family.
Port zero allocates from the bounded range 49152..50175. Ports are unique
within one protocol and address family, so TCP and UDP may share a numeric
port. Allocation failure is reported as :ephemeral_ports_exhausted.
@spec cancel(SmolNet.Socket.t(), :socket.select_info()) :: :ok | :already_sent | :not_found | {:error, :closed | :invalid_socket}
Cancels the exact pending nonblocking operation identified by select_info.
@spec close(SmolNet.Socket.t()) :: :ok | {:error, atom()}
Closes a socket and permanently invalidates its public handle.
@spec connect( SmolNet.Socket.t(), SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6() ) :: :ok | {:error, atom()}
Connects a TCP or UDP socket, waiting indefinitely by default.
@spec connect( SmolNet.Socket.t(), SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6(), :nowait | timeout() ) :: :ok | {:select, :socket.select_info()} | {:error, atom()}
Connects a TCP or UDP socket with a finite, infinite, or nonblocking timeout.
TCP :nowait returns a one-shot {:select, select_info} retry hint. Finite
and infinite waits run entirely in the caller and monitor the owning stack.
UDP connect stores a peer immediately; connected sends must use that peer and
received datagrams from other peers are discarded.
@spec getopt(SmolNet.Socket.t(), {:tcp, :nodelay} | {:socket, :keepalive}) :: {:ok, boolean()} | {:error, atom()}
Returns a TCP socket option set with setopt/3.
{:tcp, :nodelay} returns {:ok, true} when Nagle's algorithm is
disabled, and {:socket, :keepalive} when keep-alive is on; on a
listener, whether the children it accepts will have it so.
@spec grant_egress(SmolNet.Stack.Ref.t(), non_neg_integer(), non_neg_integer()) :: :ok | {:error, :invalid_egress_credit | :egress_credit_disabled | :closed}
Grants a stack started with :egress_credit more egress.
Credit counts both packets and bytes, and grants add up. The stack hands the link a packet only while the credit left covers it in both, and every batch it sends uses credit up. When credit runs out the stack holds egress back rather than dropping it: TCP data stays in the socket's send buffer and UDP datagrams in the socket's transmit ring, so senders see the backpressure they would see from a slow peer, and nothing is lost. A grant sends whatever it releases straight away.
A link typically grants back what it has forwarded:
def handle_info({:smol_stack, :my_link, :egress, packets}, state) do
Enum.each(packets, &transmit(state, &1))
:ok = SmolNet.grant_egress(state.stack, length(packets), IO.iodata_length(packets))
{:noreply, state}
endA stack waiting for credit does no work until the next grant, so timers that
need to send, such as TCP retransmissions, also wait for it. Replies to
ingress may still queue inside the stack while it waits, up to its
:output_packets limit; once that queue is full, ingress waits for credit
too.
packets and bytes are non-negative integers, each at most 0xFFFF_FFFF.
Returns {:error, :egress_credit_disabled} for a stack started without
:egress_credit. stack_info/1 reports the credit left as
native.result.egress_credit.
@spec ingress(SmolNet.Stack.Ref.t(), binary()) :: :ok | {:error, atom()}
@spec ingress(SmolNet.Stack.Ref.t(), [binary()]) :: {:ok, non_neg_integer()} | {:error, atom()}
Hands raw IPv4 or IPv6 packets from the stack's link feeder to the stack.
Each stack has one serialized feeder. This call returns after the stack owner
validates and accepts the input, then native processing runs before the stack
accepts another message. A binary preserves the single-packet :ok result.
A list is admitted atomically and returns {:ok, packet_count}; an empty list
is a no-op. A batch exceeding :input_packets or :bytes_copied is rejected
with {:error, :batch_too_large}. The feeder must bound its own transport
input.
@spec listen(SmolNet.Socket.t(), pos_integer()) :: :ok | {:error, atom()}
Turns a bound TCP socket into a reusable bounded listener.
Backlog must be in 1..128. The accepted-child queue is capped at that
value, while the native listening pool is capped at four sockets.
@spec monitor(SmolNet.Stack.Ref.t()) :: reference()
Monitors a stack from the calling process.
Returns an ordinary monitor reference. When the stack stops for any reason,
whether through stop_stack/1, a crash, or its own :link_down policy, the
caller receives {:DOWN, ref, :process, object, reason} once the complete
runtime bundle has terminated. Match on ref: object and reason describe
internal processes and are not part of the contract. A stack that has already
stopped produces the message immediately, as Process.monitor/1 does for a
dead process. Remove the monitor with Process.demonitor/2.
A link calls this so that it can exit when its stack stops, letting its supervisor rebuild both:
monitor = SmolNet.monitor(stack)
receive do
{:DOWN, ^monitor, :process, _object, _reason} -> exit(:stack_down)
end
@spec open(:inet6 | :inet, :stream | :dgram, :tcp | :udp, keyword()) :: {:ok, SmolNet.Socket.t()} | {:error, atom()}
Opens a bounded low-level TCP stream or UDP datagram socket on stack.
Family and kind are explicit and immutable. TCP and UDP support both IPv4
and IPv6. TCP accepts socket-style :rcvbuf and :sndbuf options from
1 KiB through 1 MiB; both default to 256 KiB and remain fixed after open.
@spec peername(SmolNet.Socket.t()) :: {:ok, SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6()} | {:error, atom()}
Returns the peer endpoint for a connected TCP or UDP socket.
@spec recv(SmolNet.Socket.t(), non_neg_integer()) :: {:ok, binary()} | {:error, atom() | {atom(), binary()}}
Receives TCP stream data, waiting indefinitely by default.
@spec recv(SmolNet.Socket.t(), non_neg_integer(), :nowait | timeout()) :: {:ok, binary()} | {:select, :socket.select_info()} | {:select, {:socket.select_info(), binary()}} | {:error, atom() | {atom(), binary()}}
Receives TCP stream data with a finite, infinite, or nonblocking timeout.
Positive lengths are exact for synchronous calls unless peer EOF returns the
final shorter buffered value. Length zero returns one bounded currently
available chunk. Nonblocking partial exact reads return
{:select, {select_info, partial_binary}}.
@spec recvfrom(SmolNet.Socket.t(), non_neg_integer()) :: {:ok, SmolNet.Socket.datagram()} | {:error, atom()}
Receives one UDP datagram, waiting indefinitely by default.
@spec recvfrom(SmolNet.Socket.t(), non_neg_integer(), :nowait | timeout()) :: {:ok, SmolNet.Socket.datagram()} | {:select, :socket.select_info()} | {:error, atom()}
Receives one UDP datagram with a finite, infinite, or nonblocking timeout.
Length zero returns the complete datagram. A positive length truncates a
larger datagram, discards its remainder, and sets truncated: true. Source
and actual local-destination endpoints are always returned.
@spec send(SmolNet.Socket.t(), iodata()) :: :ok | {:error, atom() | {atom(), binary()}}
Sends a complete TCP byte stream, waiting indefinitely by default.
@spec send(SmolNet.Socket.t(), iodata(), :nowait | timeout()) :: :ok | {:select, {:socket.select_info(), binary()}} | {:error, atom() | {atom(), binary()}}
Sends TCP stream data with a finite, infinite, or nonblocking timeout.
The native stack copies at most one bounded chunk. A nonblocking partial
result is {:select, {select_info, unsent_binary}}; the caller retains and
retries that remainder. A timed synchronous send that made progress returns
{:error, {:timeout, unsent_binary}}.
@spec sendto( SmolNet.Socket.t(), iodata(), SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6() ) :: :ok | {:error, atom()}
Sends one complete UDP datagram, waiting indefinitely by default.
@spec sendto( SmolNet.Socket.t(), iodata(), SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6(), :nowait | timeout() ) :: :ok | {:select, :socket.select_info()} | {:error, atom()}
Sends one complete UDP datagram with a finite, infinite, or nonblocking timeout.
The datagram is either accepted in full or not accepted. :nowait returns a
write-direction select hint when the bounded native transmit ring is full.
@spec setopt(SmolNet.Socket.t(), {:tcp, :nodelay} | {:socket, :keepalive}, boolean()) :: :ok | {:error, atom()}
Sets a socket option on a TCP socket.
The options are, as in :socket.setopt/3:
{:tcp, :nodelay}:truedisables Nagle's algorithm, so a small write is sent while an earlier one is still unacknowledged, andfalse, the default, enables it again. Disabling it sends a segment it was holding back at once.{:socket, :keepalive}:truesends keep-alive probes on a connection that has received nothing for 2 hours, one every 75 s, and fails it with:connection_timeoutonce 9 have gone unanswered, as Linux does by default;false, the default, sends none. The timing is fixed.
Set them before connecting, or at any time after. On a listener they apply
to the children accept/2 returns from then on, which each take the
listener's settings as they are returned; changing one on a child
afterwards does not affect the listener.
A UDP socket returns {:error, :invalid_socket_state}, and any other
option or value {:error, :invalid_options}.
@spec shutdown(SmolNet.Socket.t(), :read | :write | :read_write) :: :ok | {:error, atom()}
Shuts down the read half, write half, or both halves of a TCP socket.
Write shutdown drives a FIN and rejects later sends while preserving allowed reads. Read shutdown rejects later receives.
@spec sockname(SmolNet.Socket.t()) :: {:ok, SmolNet.Socket.sockaddr_in() | SmolNet.Socket.sockaddr_in6()} | {:error, atom()}
Returns the bound endpoint for a TCP or UDP socket.
@spec stack_info(SmolNet.Stack.Ref.t()) :: {:ok, map()} | {:error, :closed}
Returns ingress, link, timer, and native stack metrics.
@spec start_stack(keyword()) :: {:ok, SmolNet.Stack.Ref.t()} | {:error, term()}
Starts a raw-IP network stack.
The returned reference is opaque and owns the complete temporary runtime
bundle. Configure packet output with egress: {pid, link_ref}. Each bounded
native output batch is delivered as
{:smol_stack, link_ref, :egress, [packet, ...]}.
IPv4 addresses use {{a, b, c, d}, prefix_length} and IPv6 addresses use
{{s1, s2, s3, s4, s5, s6, s7, s8}, prefix_length}. Routes use
{destination, prefix_length, gateway}; destination and gateway must have
the same family. One stack may contain both families.
Native work limits can be reduced with the :limits option. It accepts a map
containing any of :bytes_copied, :input_packets, :output_packets,
:ready_events, and :maintenance_work; unspecified values retain their
safe defaults. :input_packets defaults to one and may be raised to 32 for
bounded batched ingress. :bytes_copied bounds, separately, the bytes one
native call copies for its operation (a send, a receive, or an ingress
batch) and the bytes of the packets it hands the link, so a send of the
whole limit still emits its first segments in the same call.
:sockets in the same map is the most sockets the stack holds at once. It
defaults to 64 and may be raised to 512. It counts native backing sockets:
one per TCP or UDP socket, one per member of a TCP listener's accept pool
(up to 4), and one per configured address for a UDP socket bound to a
wildcard address. A TCP socket that closes first keeps its slot until
TIME-WAIT ends, about 10 s after the close, so a stack whose sockets close
first sustains about sockets / 10 new connections per second. An open
beyond the limit returns {:error, :system_limit}. Each slot holds its
buffers from open until the slot is freed: a TCP socket's receive and send
buffers (256 KiB each by default, up to 1 MiB each) and 32 KiB for a UDP
socket. At the default buffer sizes, 64 TCP sockets hold about 32 MiB.
Whatever the limit, a stack's socket buffers total at most 128 MiB, what
64 TCP sockets with the largest buffers hold; an open that would pass that
also returns {:error, :system_limit}. So at the default sizes a stack
holds at most 256 TCP sockets, and 512 need buffers averaging at most
128 KiB each way. stack_info/1 reports the
slots in use as native.result.native_socket_count, the limit as
native.result.native_socket_capacity, the closed TCP sockets still
holding one as native.result.closing_tcp_socket_count, and buffer bytes
against their cap as native.result.socket_buffer_bytes and
native.result.socket_buffer_capacity.
:egress_credit limits how much egress the link must accept. It defaults to
:infinity, which sends every batch as soon as it is ready. A
{packets, bytes} tuple of non-negative integers, each at most
0xFFFF_FFFF, is the credit the stack starts with; see grant_egress/3.
An invalid value is rejected with :invalid_egress_credit.
SmolNet.Loopback.start_link/1 starts a stack whose egress is a link back
into itself, returns both references, and needs no external transport.
@spec stop_stack(SmolNet.Stack.Ref.t()) :: :ok | {:error, :closed}
Stops a stack and its complete runtime bundle.