SmolNet
SmolNet is an Elixir library that embeds the Rust
smoltcp TCP/IP stack behind a
deliberately small Rustler NIF.
Version 0.1.0 is the first release candidate. It provides independent raw-IP
dual-family stacks and bounded IPv4 and IPv6 TCP and UDP operation. TCP is
available through :gen_tcp; UDP is available through :gen_udp, with passive
and active delivery and normal controlling-process ownership.
Quick start and raw-link contract
From a source checkout, this self-contained smoke test starts a stack, inspects it, and shuts down its temporary supervision bundle:
mix run examples/quickstart.exs
address = {0xFD00, 0, 0, 0, 0, 0, 0, 1}
{:ok, stack} =
SmolNet.start_stack(
egress: {self(), :quickstart},
addresses: [{address, 64}]
)
{:ok, info} = SmolNet.stack_info(stack)
:running = info.native.result.lifecycle
:ok = SmolNet.stop_stack(stack)
SmolNet.start_stack/1 creates an independent native stack and returns an
opaque reference. A transport-neutral link process supplies complete IPv4 or IPv6
packets with SmolNet.ingress/2 and receives each emitted packet as a message:
address = {0xFD00, 0, 0, 0, 0, 0, 0, 1}
{:ok, stack} =
SmolNet.start_stack(
egress: {self(), :my_link},
mtu: 1280,
addresses: [{address, 64}, {{192, 0, 2, 1}, 24}],
link_down: :stop
)
:ok = SmolNet.ingress(stack, complete_ip_packet)
receive do
{:smol_stack, :my_link, :egress, complete_ip_packet} ->
:send_it_over_the_external_transport
end
Each stack has one serialized link feeder. Ingress waits only until the stack
owner validates and accepts the packet; bounded native processing then runs
before another stack message is accepted. This naturally limits ingress to one
packet being processed and one subsequent feeder call waiting. The feeder owns
backpressure for its external transport. Link-recipient failure can stop the
stack, retain it as marked down, or
notify another process. SmolNet.stop_stack/1 stops the complete temporary
supervision bundle.
Low-level socket values are lightweight %SmolNet.Socket{} structs. Native
socket IDs and generations are globally monotonic, never reused, and capped at
2^59 - 1 so they remain immediate integers on the supported 64-bit BEAM
targets. Each stack also caps live public socket entries at its :ready_events
limit; opening or promoting an accepted child beyond that bound returns or
applies the documented bounded-overflow policy. A listener may own up to four
additional internal pool sockets per public listener identity. A blocked
nonblocking operation returns {:select_info, operation, reference}; its
one-shot message has this shape:
{:"$smol_socket", {socket.id, socket.generation}, :select, reference}
The message is only a retry hint. SmolNet.cancel/2 removes the exact waiter
and returns :ok, :already_sent, or :not_found according to which side of
the readiness race won.
Low-level TCP streams
TCP endpoints use explicit :socket-style IPv4 or IPv6 maps. The family is
fixed by SmolNet.open/4; mismatched endpoints are rejected rather than
converted. IPv4-mapped IPv6 addresses are intentionally unsupported.
Synchronous calls wait in the
calling process using one monotonic deadline; the stack owner and native
scheduler never wait for traffic. Finite timeouts use milliseconds in
0..4_294_967_295. Passing :nowait exposes the same one-shot retry primitive
directly.
local = {0xFD00, 0, 0, 0, 0, 0, 0, 1}
remote = {0xFD00, 0, 0, 0, 0, 0, 0, 2}
{:ok, stack} =
SmolNet.start_stack(
egress: {link_pid, :link_a},
addresses: [{local, 64}]
)
{:ok, socket} = SmolNet.open(:inet6, :stream, :tcp, stack: stack)
: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 = SmolNet.shutdown(socket, :write)
{:ok, local_endpoint} = SmolNet.sockname(socket)
{:ok, %{addr: ^remote, port: 443}} = SmolNet.peername(socket)
:ok = SmolNet.close(socket)
The nonblocking stream shapes keep every continuation 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 one-shot message, retry only `unsent`, or request the
# remaining receive length while retaining `partial` in the caller.
:ok = SmolNet.cancel(socket, send_info)
:ok = SmolNet.cancel(socket, recv_info)
recv(socket, 0, timeout) returns one bounded currently available chunk. A
positive synchronous length accumulates bounded reads until exact completion,
timeout, error, or peer EOF. EOF returns buffered data first; the next receive
returns {:error, :closed}. Reset remains :connection_reset. If a timeout or
error follows partial progress, send returns the unsent remainder and receive
returns accumulated data in {reason, continuation}.
Each TCP socket has fixed 4096-byte native RX and TX buffers. Automatic ports
come from the bounded 49152..50175 range. Bind reservations are unique within
one address family, so one dual-family stack may bind the same port once for
IPv4 and once for IPv6; accepted children retain their listener's local port.
Link-local fe80::/10 endpoints require a positive integer scope_id; global
addresses use scope zero. The native layer never stores arbitrary unsent
payloads or exact-receive accumulation. Established close invalidates the
public handle immediately, then retains one bounded native closing record long
enough to drive FIN and retransmission, with a 30-second deadline measured from
the close call; closing records continue to count against the socket limit.
The public module documentation lists the stable validation, timeout,
connection, stream, lifecycle, and handle errors.
IPv4 endpoints use %{family: :inet, addr: {192, 0, 2, 2}, port: 443} and
otherwise have the same connect, stream, timeout, cancellation, half-close,
and close behavior. A stack may contain both four-octet IPv4 and eight-segment
IPv6 addresses and routes. Each route's destination and gateway must have the
same family.
Raw IPv4 ingress validates the IHL, exact total length, header checksum, and MTU before native mutation. Fragmented IPv4 input (a nonzero fragment offset or the more-fragments flag) is rejected; reassembly is outside this raw-link API. IPv4 limited broadcast is rejected for interface addresses, gateways, and TCP endpoints.
Low-level TCP listeners
A bound stream socket becomes a reusable listener with SmolNet.listen/2.
Backlogs are integers in 1..128. Each listener maintains
min(backlog, 4) native listening sockets so one smoltcp socket can be
promoted into a connected child without pretending that it remains reusable.
Every promoted child receives a fresh public socket ID and generation, and the
native pool is replenished. Half-open handshakes expire after 30 seconds and
their pool slots are replenished from timer-driven maintenance.
Wildcard listeners remain scoped to their explicit socket family on a
dual-family stack and accept any configured local address in that family.
{: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)
SmolNet.accept/2 supports finite and infinite waits plus :nowait, using the
same read-direction select/cancel contract as receive. The accepted-child queue
never exceeds the requested backlog. If another connection becomes established
while that queue is full—or the public socket table cannot allocate its child
identity—the newest child is dropped and its pool slot is replenished. The peer
may observe handshake completion first; its next traffic receives a reset from
the now-unmatched connection. Listener scans and replenishment are charged to
the stack's maintenance_work bound and expose pool, queue, promotion, refill,
and overflow metrics through SmolNet.stack_info/1.
Closing a listener aborts a pending accept and immediately releases queued children and listening or half-open pool members. Already-returned children are independent and remain usable. Stack shutdown releases both listener and child state.
Low-level IPv4 and IPv6 UDP
Open UDP with SmolNet.open(family, :dgram, :udp, stack: stack), where
family is :inet or :inet6. UDP and TCP have independent port namespaces,
and the two address families are also isolated, so all four combinations may
bind the same numeric port on one stack.
local = %{family: :inet6, addr: local_address, port: 0}
peer = %{family: :inet6, addr: peer_address, port: 53}
{:ok, socket} = SmolNet.open(:inet6, :dgram, :udp, stack: stack)
:ok = SmolNet.bind(socket, local)
:ok = SmolNet.sendto(socket, <<0, 1, "query">>, peer, 5_000)
{:ok, datagram} = SmolNet.recvfrom(socket, 0, 5_000)
%{source: source, destination: destination, data: payload, truncated: false} = datagram
Each native UDP socket has fixed receive and transmit rings of 16 packet
metadata entries and 16 KiB of payload. The maximum accepted datagram payload
is min(stack_mtu - header_bytes, 16_384), where header_bytes is 28 for IPv4
and 48 for IPv6. A larger send returns :message_too_large; the gen_udp
adapter translates that error to :emsgsize. A send is accepted in full or not
at all. When the transmit ring is full, :nowait returns a write-direction
select hint and a retry uses the original complete datagram—there is no partial
progress.
A wildcard bind uses one family-specific native backing socket per configured local address, capped by the stack's eight-address configuration limit. This keeps IPv4 and IPv6 wildcard sockets on the same port isolated despite smoltcp's family-neutral wildcard endpoint. All backing sockets share one logical identity, one transmit ring, and one read waiter; receive inspection remains capped at 16 datagrams per call. Wildcard sends retain the stack's destination-aware IPv4 and IPv6 source-address selection.
recvfrom(socket, 0, timeout) returns one complete datagram, including a
zero-length datagram. A positive length returns at most that many bytes,
discards the remainder of that datagram, and reports truncated: true. Source
and the packet's actual local destination are retained. Invalid nonzero UDP
checksums are discarded by the native stack. IPv4's standard zero checksum is
accepted, while IPv6 requires a checksum. An unreachable destination fails
before queueing with :network_unreachable.
UDP uses the same one-shot read/write waiter table, cancellation result, caller
deadline loop, ready-event sweep, timer propagation, and output bounds as TCP.
One read and one write waiter may coexist. Closing a UDP socket invalidates it
immediately and aborts both waiters; UDP has no retained graceful-close state.
SmolNet.connect/2 stores a default peer, restricts later sends to that peer,
and discards incoming datagrams from other peers.
gen_tcp IPv4 and IPv6 clients and servers
Select the SmolNet backend with {:tcp_module, SmolNet.InetBackend.Tcp} and
identify the target stack with {:smolnet_stack, stack}. The returned socket
works with the standard :gen_tcp and :inet client operations implemented by
this phase:
options = [
{:tcp_module, SmolNet.InetBackend.Tcp},
{:smolnet_stack, stack},
:inet6,
:binary,
{:active, false},
{:packet, :raw}
]
{:ok, socket} = :gen_tcp.connect(remote, 443, options, 5_000)
:ok = :gen_tcp.send(socket, "request")
{:ok, response} = :gen_tcp.recv(socket, 0, 5_000)
:ok = :gen_tcp.shutdown(socket, :write)
:ok = :gen_tcp.close(socket)
For IPv4, use SmolNet.InetBackend.Tcp4 and :inet; all socket operations use
the shared adapter implementation:
ipv4_options = [
{:tcp_module, SmolNet.InetBackend.Tcp4},
{:smolnet_stack, stack},
:inet,
:binary,
{:active, false}
]
{:ok, socket} = :gen_tcp.connect({192, 0, 2, 2}, 443, ipv4_options, 5_000)
The same options can create a server. backlog defaults to 5 and accepts
values in 1..128; accepted sockets inherit the listener's supported active,
mode, packet, packet-size, receive-buffer, and send-timeout options.
server_options = [{:backlog, 16} | options]
{:ok, listener} = :gen_tcp.listen(8080, server_options)
{:ok, socket} = :gen_tcp.accept(listener, 5_000)
{:ok, request} = :gen_tcp.recv(socket, 0, 5_000)
:ok = :gen_tcp.send(socket, request)
The adapter supports :binary and :list, packet modes :raw, :line, 1,
2, and 4, and active: false | true | :once | N for N in
1..32_767. Counted active mode counts complete logical packets and emits
{:tcp_passive, socket} when exhausted. Active delivery is limited to 16
native reads or logical packets per mailbox turn.
The receive buffer and packet_size default to 65,536 bytes and each is capped
at 1 MiB. Oversized frames return :emsgsize and close the socket because the
stream cannot be resynchronized. Passive raw receives larger than the current
receive bound also return :emsgsize. One read and one write may proceed at
the same time; a second operation in the same direction returns :busy.
send_timeout and send_timeout_close control a blocked adapter send.
Each OTP socket is a temporary process under its stack bundle. The controlling
process owns active messages, and :gen_tcp.controlling_process/2 transfers
queued and future messages in order. Owner death, adapter death, or stack
failure closes the low-level socket without affecting independent stack
bundles. A listener has its own adapter state, while each accepted child gets a
separate connected-stream adapter owned by the process that called
:gen_tcp.accept/2.
gen_udp IPv4 and IPv6 sockets
Select the IPv6 backend with {:udp_module, SmolNet.InetBackend.Udp} and pass
the target stack with {:smolnet_stack, stack}:
options = [
{:udp_module, SmolNet.InetBackend.Udp},
{:smolnet_stack, stack},
:inet6,
:binary,
{:active, false}
]
{:ok, socket} = :gen_udp.open(0, options)
:ok = :gen_udp.send(socket, peer_address, 53, "query")
{:ok, {source_address, source_port, response}} = :gen_udp.recv(socket, 0, 5_000)
:ok = :gen_udp.connect(socket, peer_address, 53)
:ok = :gen_udp.send(socket, "connected query")
:ok = :gen_udp.close(socket)
For IPv4, select SmolNet.InetBackend.Udp4 and :inet. The callback supplies
IPv4 name resolution while all socket operations use the same bounded adapter
state machine:
ipv4_options = [
{:udp_module, SmolNet.InetBackend.Udp4},
{:smolnet_stack, stack},
:inet,
:binary,
{:active, false}
]
{:ok, socket} = :gen_udp.open(0, ipv4_options)
:ok = :gen_udp.send(socket, {192, 0, 2, 2}, 53, "query")
Supported UDP socket policy is :binary or :list,
active: false | true | :once | N for N in 1..32_767, and bounded
buffer/recbuf values from 1 byte through 1 MiB. The buffer caps the payload
returned for both passive and active receives; a larger datagram is truncated
and its remainder discarded, as with the standard callback's unreported receive
truncation. Active messages have the standard
{:udp, socket, source_address, source_port, packet} shape; counted mode emits
{:udp_passive, socket} when exhausted. Each adapter mailbox turn delivers at
most 16 datagrams before yielding. Packet framing, send timeouts, ancillary
data, multicast, broadcast, raw options, and file descriptors are unsupported
and fail explicitly.
Every UDP socket is a temporary state-machine child under its stack bundle.
Read and write continuations are independent, competing operations in one
direction return :busy, controlling-process transfer moves queued and future
active messages, and owner or adapter death closes only that socket. Stack
failure terminates its adapters with :enetdown without affecting another
stack bundle.
Inet option contract
The callback option surface is deliberately finite. Invalid values, options in
the wrong lifecycle phase, and any option not listed as supported return
:einval; selecting the other family returns :eafnosupport.
| Option | TCP open/listen/connect | TCP runtime | UDP open | UDP runtime |
|---|---|---|---|---|
:smolnet_stack |
required | fixed | required | fixed |
:inet / :inet6 |
supported | fixed | supported | fixed |
:binary / :list / :mode |
supported | supported | supported | supported |
:active |
false, true, :once, or 1..32767 |
supported | same | supported |
:packet |
:raw, :line, 1, 2, or 4 |
supported | unsupported | unsupported |
:packet_size |
0..1048576 |
supported | unsupported | unsupported |
:buffer / :recbuf |
1..1048576 |
supported | supported | supported |
:send_timeout / :send_timeout_close |
supported | supported | unsupported | unsupported |
:ip / :ifaddr / :port |
supported | fixed | supported | fixed |
:backlog |
listen only, 1..128 |
fixed | unsupported | unsupported |
:ipv6_v6only |
IPv6 true only |
fixed | IPv6 true only |
fixed |
Ancillary data, multicast, broadcast, OS file descriptors, raw socket options, and every other TCP or UDP inet option are outside the first-release contract and fail explicitly.
Architecture and supervision
Each call to SmolNet.start_stack/1 creates a temporary supervision bundle
containing one stack owner process and one dynamic supervisor for its inet
adapters. The stack process exclusively owns the native resource and serializes
all access. Native calls use a nonblocking try_lock, perform bounded work, and
return immediately; readiness waits, application deadlines, framing, and active
mode remain in Elixir processes. A stack or adapter failure cannot corrupt
another bundle, and owner/link failure follows the documented lifecycle policy.
The Rust workspace separates the reusable smolnet_core engine from the thin
smolnet_nif Rustler entry point. The engine contains packet, socket, waiter,
timer, and bounds logic. Elixir owns supervision, raw-link delivery, timer
replacement, synchronous retry loops, and OTP inet compatibility. Native output
is delivered after every driving operation, and each returned poll_at replaces
the prior BEAM timer.
Error contract
Public low-level calls return :ok, {:ok, value}, {:select, continuation},
or {:error, reason}. Validation failures are stable atoms such as
:unsupported_family, :invalid_options, :invalid_address, and
:message_too_large; lifecycle failures use :closed, :invalid_socket, or
:invalid_socket_state; network failures include :network_unreachable,
:connection_timeout, :connection_refused, and :connection_reset.
Concurrent operations in the same readiness direction return :busy. Inet
adapters translate these into the documented OTP-style atoms such as :einval,
:eafnosupport, :emsgsize, and :enetdown. Treat a select notification only
as permission to retry.
Migrating to 0.1.0
This is the first public release candidate, so there is no earlier supported release API to migrate from. Users of development snapshots should update to the explicit IPv4/IPv6 endpoint maps and inet backend modules shown above, remove assumptions that socket IDs can be reused, and handle nowait operations through their returned select continuations. Repository checkouts continue to compile the NIF from source. Hex consumers on supported GNU/Linux and Apple Silicon macOS targets download a checksum-pinned precompiled NIF.
Development
The supported development baseline is Elixir 1.19.5, Erlang/OTP 28.3, and Rust 1.94.0. The compatibility matrix additionally covers the supported Elixir 1.18–1.20 and OTP 27–29 combinations on Linux and macOS.
Run the complete local quality gate before committing:
mix precommit
Maintainers cutting a release should follow the complete Publisho, native asset, checksum, and Hex sequence in MAINTAINING.md.
Native builds
Hex consumers on GNU/Linux x86_64/AArch64 and Apple Silicon macOS download a
precompiled NIF for the exact SmolNet version. Each archive is verified against
the checksum pinned inside the Hex package, validated to contain exactly one
regular NIF file, and then extracted. Linux assets target glibc 2.35 or newer
and may depend only on glibc's standard libc, libm, libdl, libpthread,
and librt libraries plus libgcc_s. The Apple Silicon asset targets macOS
14 or newer and may depend only on libSystem. Alpine, other musl systems, and
Intel macOS are unsupported as Hex-package targets.
Repository checkouts and CI always compile from source so native changes cannot be hidden by a restored or downloaded artifact. Source builds require Rust 1.91 or newer, a platform C linker, and Erlang development files; Rust 1.94.0 is the pinned development version. Hex packages intentionally omit the Rust workspace. Unsupported Hex-consumer targets fail with a list of supported targets and should use a source checkout if they need to build locally.
Every native stack call has a 1 ms normal-scheduler target. Native work stops
at a monotonic 750 microsecond deadline, reserving 250 microseconds for result
encoding and handoff to the BEAM. Output packets are allocated as Rustler
OwnedBinary values and released into the result without a second payload
copy. Output, readiness and overflow delivery, listener and closing
maintenance, and shutdown retain their cursors or queues when the deadline is
reached; more: true asks the stack owner to run the next bounded slice.
Native calls also report their measured scheduler share through
enif_consume_timeslice, so repeated continuations yield fairly to other
stack processes and ordinary mailbox traffic.
Ingress remains a single-feeder interface with one admitted packet at a time.
When a deadline continuation still owns that slot, another feeder call can
return {:error, :busy} sooner than it did under quota-only batching. Feeders
should treat :busy as backpressure and retry after yielding or waiting for
their next input opportunity.
The time budget is backed by deterministic per-call maxima of 65,575 copied
bytes, 32 output packets, 128 readiness events, and 128 maintenance units.
These bounds prevent clock or platform anomalies from creating unbounded work.
A separate hard ceiling allows at most 64 native TCP/UDP backing sockets per
stack, including listener pools and wildcard-UDP expansion across configured
addresses. SmolNet.stack_info/1 exposes the call target, work budget,
encoding headroom, deadline-yield count, timeslice-exhaustion count, and
maximum observed serialized native-call duration before result encoding.
Local mix precommit enforces a 1 ms maximum for complete Elixir-visible NIF
calls, including result encoding. The GitHub-hosted x86_64 Linux quality job
plus the AArch64 Linux and macOS native-budget jobs enforce the full-call p99
while retaining the absolute maximum as evidence, avoiding false failures from
host preemption. Each maximum-state scenario uses 100 independently prepared
samples in p99 mode.
Unexpected resource destruction remains synchronously bounded by the fixed
socket, waiter, and packet capacities and is included in the benchmark.
Troubleshooting
- If a source build fails, confirm Rust 1.91 or newer is active and that the platform C linker and Erlang development files are installed.
- If a Hex dependency reports that no precompiled NIF is available, confirm the host is glibc-based GNU/Linux on x86_64/AArch64 or Apple Silicon running macOS 14 or newer. Other targets require a source checkout; there is no implicit source fallback in the Hex package.
- If ingress returns a validation error, supply exactly one complete IPv4 or IPv6 packet within the configured MTU. Ethernet frames and fragmented IPv4 packets are not accepted.
- If an operation times out despite no inbound traffic, ensure the stack owner remains alive. SmolNet schedules retransmission timers itself, but the raw transport must forward emitted packets and return peer traffic.
- Use
SmolNet.stack_info/1to inspect lifecycle, bounds, socket counts, and listener/queue metrics before reporting a failure.
Status
Version 0.1.0 is prepared as a release candidate but has not been tagged,
published to Hex, or released. RELEASE.md will be introduced only after the
first release has actually been published.
License
SmolNet is released under the MIT License. See LICENSE.