SmolNet (smolnet v0.7.1)

Copy Markdown View Source

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.

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

accept(listener)

@spec accept(SmolNet.Socket.t()) :: {:ok, SmolNet.Socket.t()} | {:error, atom()}

Accepts a TCP child, waiting indefinitely by default.

accept(listener, timeout_or_nowait)

@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.

bind(socket, address)

@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.

cancel(socket, select_info)

@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.

close(socket)

@spec close(SmolNet.Socket.t()) :: :ok | {:error, atom()}

Closes a socket and permanently invalidates its public handle.

connect(socket, address)

@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.

connect(socket, address, timeout_or_nowait)

@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.

getopt(socket, option)

@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.

grant_egress(stack, packets, bytes)

@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}
end

A 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.

ingress(stack, packet)

@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.

listen(socket, backlog)

@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.

monitor(stack)

@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

open(domain, type, protocol, options)

@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.

peername(socket)

@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.

recv(socket, length)

@spec recv(SmolNet.Socket.t(), non_neg_integer()) ::
  {:ok, binary()} | {:error, atom() | {atom(), binary()}}

Receives TCP stream data, waiting indefinitely by default.

recv(socket, length, timeout_or_nowait)

@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}}.

recvfrom(socket, length)

@spec recvfrom(SmolNet.Socket.t(), non_neg_integer()) ::
  {:ok, SmolNet.Socket.datagram()} | {:error, atom()}

Receives one UDP datagram, waiting indefinitely by default.

recvfrom(socket, length, timeout_or_nowait)

@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.

send(socket, data)

@spec send(SmolNet.Socket.t(), iodata()) ::
  :ok | {:error, atom() | {atom(), binary()}}

Sends a complete TCP byte stream, waiting indefinitely by default.

send(socket, data, timeout_or_nowait)

@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}}.

sendto(socket, data, address)

@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.

sendto(socket, data, address, timeout_or_nowait)

@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.

setopt(socket, option, value)

@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}: true disables Nagle's algorithm, so a small write is sent while an earlier one is still unacknowledged, and false, the default, enables it again. Disabling it sends a segment it was holding back at once.
  • {:socket, :keepalive}: true sends keep-alive probes on a connection that has received nothing for 2 hours, one every 75 s, and fails it with :connection_timeout once 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}.

shutdown(socket, how)

@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.

sockname(socket)

@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.

stack_info(stack)

@spec stack_info(SmolNet.Stack.Ref.t()) :: {:ok, map()} | {:error, :closed}

Returns ingress, link, timer, and native stack metrics.

start_stack(options \\ [])

@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.

stop_stack(stack)

@spec stop_stack(SmolNet.Stack.Ref.t()) :: :ok | {:error, :closed}

Stops a stack and its complete runtime bundle.