The SmolNet module exposes a small, :socket-style API directly over an
embedded stack. It uses explicit address maps, timeout arguments, and one-shot
readiness notifications instead of an adapter process. It is similar in shape
to Erlang's :socket API, but it is not a drop-in implementation of that
module.
Choose this interface when the application wants precise control over socket
state and nonblocking retries. Choose the :gen_tcp or :gen_udp adapters
when existing code expects inet options, active messages, packet framing, or
controlling-process transfers.
Create a stack and link
A stack owns its addresses, routes, sockets, protocol timers, and native resource. Its link carries complete raw IP packets to and from an application-defined transport:
local = {0xFD00, 0, 0, 0, 0, 0, 0, 1}
gateway = {0xFD00, 0, 0, 0, 0, 0, 0, 0xFF}
{:ok, stack} =
SmolNet.start_stack(
egress: {self(), :tunnel},
mtu: 1280,
addresses: [{local, 64}],
routes: [{{0, 0, 0, 0, 0, 0, 0, 0}, 0, gateway}],
link_down: :stop
)The egress recipient receives non-empty, bounded batches in packet order:
{:smol_stack, :tunnel, :egress, [complete_ip_packet, ...]}Feed one complete IPv4 or IPv6 packet back, or atomically admit a configured bounded batch, with:
:ok = SmolNet.ingress(stack, complete_ip_packet)
{:ok, 2} = SmolNet.ingress(stack, [first_ip_packet, second_ip_packet])Ingress accepts raw IP packets, not Ethernet frames. IPv4 input is validated
for header length, total length, checksum, MTU, and fragmentation before it
mutates the stack. Fragmented IPv4 input is rejected; reassembly belongs
outside this API. Batch size is controlled by the stack's input_packets
limit, which defaults to one and may be raised to 32. The sum of batch payload
bytes must also fit bytes_copied; over-limit batches are rejected atomically.
Each stack has one serialized link feeder. The link is responsible for
transport-level buffering and backpressure. If a bounded native continuation
still owns the ingress slot, a feeder can receive {:error, :busy} and should
retry after yielding. The :link_down policy may be :stop, :mark_down, or
{:notify, pid}. Under :stop, the default, a stack whose link exits stops
too, and :gen_tcp and :gen_udp calls pending on its sockets fail with
:enetdown. Under :mark_down the stack keeps running without a link: it
rejects ingress with {:error, :link_down} and drops egress, and its sockets
stay open until they are closed or the stack is stopped. {:notify, pid} does
the same and also sends pid {:smol_stack, link_ref, :link_down, reason}.
A link cannot refuse an egress batch once it arrives. A link with a bounded queue should start its stack with egress credit and grant it back as it forwards packets:
{:ok, stack} = SmolNet.start_stack(egress: {self(), :tunnel}, egress_credit: {128, 256 * 1024})
# after forwarding a batch
:ok = SmolNet.grant_egress(stack, length(packets), IO.iodata_length(packets))The stack then sends only what the credit covers, in both packets and bytes, and holds the rest in its sockets: TCP data stays in the send buffer and UDP datagrams in the transmit ring, so senders see ordinary backpressure and nothing the link accepted has to be dropped.
The stack monitors its link, and the link can monitor the stack in return.
SmolNet.monitor/1 returns an ordinary monitor reference, and the link receives
{:DOWN, monitor, :process, _object, _reason} when the stack stops, whether
through SmolNet.stop_stack/1 or a crash. Match on the reference; the object
and reason are internal. A link that owns its transport can exit on that
message and let its supervisor rebuild both.
For examples and tests, SmolNet.Loopback supplies a link that feeds every
emitted packet back into the same stack:
{:ok, _link, stack} =
SmolNet.Loopback.start_link(addresses: [{{127, 0, 0, 1}, 8}])Endpoint maps
IPv4 and IPv6 are explicit:
ipv4 = %{family: :inet, addr: {192, 0, 2, 2}, port: 443}
ipv6 = %{
family: :inet6,
addr: {0xFD00, 0, 0, 0, 0, 0, 0, 2},
port: 443
}IPv6 maps may also contain flowinfo: 0 and scope_id: 0. A link-local
fe80::/10 endpoint requires a positive scope_id; a global address uses
scope zero. IPv4-mapped IPv6 addresses are intentionally unsupported.
A socket's family is fixed by SmolNet.open/4. Passing an endpoint from the
other family returns an error rather than converting it.
TCP clients
Open, optionally bind, and connect a stream socket:
remote = {0xFD00, 0, 0, 0, 0, 0, 0, 2}
{:ok, socket} =
SmolNet.open(:inet6, :stream, :tcp,
stack: stack,
rcvbuf: 192 * 1024,
sndbuf: 192 * 1024
)
:ok =
SmolNet.bind(socket, %{
family: :inet6,
addr: local,
port: 0
})
peer = %{family: :inet6, addr: remote, port: 443}
:ok = SmolNet.connect(socket, peer, 5_000)
:ok = SmolNet.send(socket, ["hello", " world"], 5_000)
{:ok, response} = SmolNet.recv(socket, 128, 5_000)
{:ok, local_endpoint} = SmolNet.sockname(socket)
{:ok, %{family: :inet6, addr: ^remote, port: 443}} =
SmolNet.peername(socket)
:ok = SmolNet.shutdown(socket, :write)
:ok = SmolNet.close(socket)TCP receive and transmit buffers default to 256 KiB. rcvbuf and sndbuf
accept sizes from 1 KiB through 1 MiB and cannot be resized after opening.
Nagle's algorithm is on by default. Turn it off, as TCP_NODELAY does, with
{:tcp, :nodelay}, before connecting or at any time after; turning it off
sends a segment it was holding at once:
:ok = SmolNet.setopt(socket, {:tcp, :nodelay}, true)
{:ok, true} = SmolNet.getopt(socket, {:tcp, :nodelay})Keep-alive is off by default. {:socket, :keepalive} turns it on, as
SO_KEEPALIVE does, with Linux's default timing, which is fixed: a probe
after 2 hours in which nothing arrives, then one every 75 s, and
{:error, :connection_timeout} once 9 have gone unanswered. Set later, the
2 hours count from the last packet received:
:ok = SmolNet.setopt(socket, {:socket, :keepalive}, true)
{:ok, true} = SmolNet.getopt(socket, {:socket, :keepalive})A connection with data or a FIN unacknowledged, or data a zero window holds
back, that hears nothing from its peer for 924.6 s fails with
:connection_timeout too: the user timeout of RFC 5482, fixed at how long
Linux gives up after by default. A pending recv or send returns it at
once, and so does every later call but close. An idle connection, with
nothing outstanding, never times out without keep-alive.
recv(socket, 0, timeout) returns one bounded currently available chunk. A
positive synchronous length accumulates bounded reads until it has exactly
that many bytes, the operation fails, or the peer reaches EOF. EOF returns
buffered data first; the next receive returns {:error, :closed}. If a timeout
or error follows partial progress, receive returns the accumulated bytes with
the reason; send similarly returns its unsent remainder.
TCP listeners
A bound stream socket becomes a listener with a backlog from 1 through 128:
{:ok, listener} = SmolNet.open(:inet6, :stream, :tcp, stack: stack)
:ok =
SmolNet.bind(listener, %{
family: :inet6,
addr: local,
port: 8080
})
:ok = SmolNet.listen(listener, 16)
{:ok, child} = SmolNet.accept(listener, 5_000)
{:ok, request} = SmolNet.recv(child, 0, 5_000)
:ok = SmolNet.send(child, request, 5_000)Each accepted child has a new public identity and becomes independent of its
listener. It takes the listener's {:tcp, :nodelay} and
{:socket, :keepalive} settings as accept returns it. A listener maintains up to four native listening sockets and an
accepted queue no larger than its requested backlog. Closing the listener
aborts a pending accept and releases queued children; children already returned
to callers remain usable.
Wildcard listeners remain restricted to the family chosen at open, even on a dual-family stack.
UDP sockets
Open datagram sockets with :dgram and :udp:
local_endpoint = %{family: :inet6, addr: local, port: 0}
peer_endpoint = %{family: :inet6, addr: remote, port: 53}
{:ok, socket} = SmolNet.open(:inet6, :dgram, :udp, stack: stack)
:ok = SmolNet.bind(socket, local_endpoint)
:ok = SmolNet.sendto(socket, <<0, 1, "query">>, peer_endpoint, 5_000)
{:ok, datagram} = SmolNet.recvfrom(socket, 0, 5_000)
%{
source: source,
destination: destination,
data: payload,
truncated: false
} = datagramA zero receive length returns one complete datagram, including a zero-length
datagram. A positive length returns at most that many bytes, discards the rest
of the same datagram, and sets truncated: true. Both the source endpoint and
the packet's actual local destination are retained.
Each native UDP socket has rings for 16 packet descriptors and 16 KiB of payload. A datagram payload may not exceed the smaller of 16,384 bytes and the stack MTU minus 28 bytes for IPv4 or 48 bytes for IPv6. A send is accepted in full or not at all.
SmolNet.connect/2 records a default UDP peer. Later sends are restricted to
that peer, and datagrams from other peers are discarded. TCP and UDP have
independent port namespaces, and IPv4 and IPv6 are also isolated, so all four
combinations can bind the same numeric port on one stack.
Nonblocking operations
Pass :nowait to connect, accept, send, receive, or datagram operations. When
the operation would block, it returns a one-shot select value:
{:select, {:select_info, _operation, reference} = select_info} =
SmolNet.recv(socket, 0, :nowait)
socket_identity = {socket.id, socket.generation}
receive do
{:"$smol_socket", ^socket_identity, :select, ^reference} ->
SmolNet.recv(socket, 0, :nowait)
{:"$smol_socket", ^socket_identity, :abort, ^reference, reason} ->
{:error, reason}
endThe message is only a retry hint; readiness can change before the retry. The
select is one-shot, so each blocked retry returns a new select value. Closing
the socket or stack can send the corresponding :abort message instead. Its
reason is :closed when the socket closes, or when SmolNet.stop_stack/1
or a supervisor's shutdown stops the stack. A stack that stops on its own
aborts its waiters with :link_down when its link died under
link_down: :stop, and with :stack_down when it failed. Blocking calls
return {:error, :closed} whenever their stack stops.
Stream operations retain partial progress in the caller:
{:select, {send_info, unsent}} =
SmolNet.send(socket, large_binary, :nowait)
{:select, {recv_info, partial}} =
SmolNet.recv(socket, exact_length, :nowait)After the matching notification, retry only unsent, or request the remaining
receive length while retaining partial in the caller. UDP never returns
partial send progress; retry the original complete datagram.
Cancel a waiter with its exact select value:
case SmolNet.cancel(socket, select_info) do
:ok -> :cancelled
:already_sent -> :notification_won_the_race
:not_found -> :no_matching_waiter
{:error, reason} -> {:socket_unavailable, reason}
endOne read-direction and one write-direction waiter can coexist. A competing
operation in the same direction returns :busy.
Resource and error contracts
%SmolNet.Socket{} values are lightweight identities, not processes. Socket
IDs and generations are never reused. Closing a socket invalidates its public
handle immediately; a TCP closing record can remain temporarily to drive FIN
and retransmission.
A stack has bounded socket, packet, waiter, readiness, and maintenance state.
The sockets limit caps the native backing sockets per stack, including
listener pools, wildcard UDP expansion, and TCP sockets still in TIME-WAIT. It
defaults to 64 and may be raised to 512 with
SmolNet.start_stack(limits: %{sockets: n}); each slot holds its socket's
buffers until it is freed, and a stack's socket buffers total at most
128 MiB. An open beyond either limit returns {:error, :system_limit}. SmolNet.stack_info/1 reports live usage, buffer
capacities, queue metrics, and work-budget measurements.
Public calls return :ok, {:ok, value}, {:select, continuation}, or
{:error, reason}. Stable errors distinguish validation, lifecycle, and
network failures. Common examples include :invalid_options,
:invalid_address, :message_too_large, :network_unreachable,
:connection_refused, :connection_reset, :connection_timeout, :busy,
:closed, and :invalid_socket. Treat each select notification only as
permission to retry.