SmolNet can back Erlang's :gen_udp and :inet APIs. Use this interface for OTP-style datagram sockets, including passive receives, active messages, connected UDP, and controlling-process ownership.

Select the SmolNet backend

Every socket needs two SmolNet-specific options:

  • {:udp_module, module} selects the callback for its address family.
  • {:smolnet_stack, stack} selects the independent stack that owns it.

Use matching callback and family options:

FamilyCallbackFamily option
IPv4SmolNet.Inet.Udp:inet
IPv6SmolNet.Inet6.Udp:inet6

For example, these are passive binary IPv6 options:

options = [
  {:udp_module, SmolNet.Inet6.Udp},
  {:smolnet_stack, stack},
  :inet6,
  :binary,
  {:active, false}
]

The callback option affects only sockets created with that option list. It does not replace the node-wide inet backend.

A complete loopback example

This example opens two IPv4 UDP sockets on one looped stack and sends a datagram between them:

{:ok, _link, stack} =
  SmolNet.Loopback.start_link(addresses: [{{127, 0, 0, 1}, 8}])

options = [
  {:udp_module, SmolNet.Inet.Udp},
  {:smolnet_stack, stack},
  :inet,
  :binary,
  {:active, false},
  {:ip, {127, 0, 0, 1}}
]

{:ok, receiver} = :gen_udp.open(0, options)
{:ok, {{127, 0, 0, 1}, receiver_port}} = :inet.sockname(receiver)

{:ok, sender} = :gen_udp.open(0, options)
:ok = :gen_udp.send(sender, {127, 0, 0, 1}, receiver_port, "ping")

{:ok, {{127, 0, 0, 1}, _sender_port, "ping"}} =
  :gen_udp.recv(receiver, 0, 5_000)

:ok = :gen_udp.close(sender)
:ok = :gen_udp.close(receiver)
:ok = SmolNet.stop_stack(stack)

Port zero chooses an ephemeral port from the stack's bounded range. UDP and TCP have separate port namespaces, as do IPv4 and IPv6.

Sending and receiving

Open, send, and receive with the normal :gen_udp API:

{: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)

Each send accepts one complete datagram or fails without accepting any of it. The payload limit is the smaller of 16,384 bytes and the configured MTU minus the IP and UDP headers. An oversized datagram returns :emsgsize.

recv(socket, 0, timeout) returns the complete next datagram, including a zero-length datagram. A positive receive length truncates a larger datagram and discards the remainder. The :gen_udp result does not separately report that truncation, matching the callback contract; use the low-level SmolNet API if the application needs a truncated flag or the packet's local destination.

Connected UDP

Connecting records a default peer; it does not perform a handshake:

:ok = :gen_udp.connect(socket, peer_address, 53)
:ok = :gen_udp.send(socket, "connected query")

After connection, sends must target that peer and incoming datagrams from other peers are discarded. :inet.peername/1 returns the configured peer.

Active delivery

Active mode supports true, :once, and counts from 1 through 32,767:

:ok = :inet.setopts(socket, active: :once)

receive do
  {:udp, ^socket, source_address, source_port, packet} ->
    handle_datagram(source_address, source_port, packet)

  {:udp_error, ^socket, reason} ->
    handle_error(reason)
end

Counted mode sends {:udp_passive, socket} when its count is exhausted. Each adapter mailbox turn delivers at most 16 datagrams before yielding. Switch back to passive mode with :inet.setopts(socket, active: false) before calling :gen_udp.recv/3.

Only the controlling process receives active messages. Transfer ownership with :gen_udp.controlling_process/2; queued matching messages and future delivery move to the new owner in order. If the owner exits, the adapter and its low-level socket close.

Buffers and wildcard binds

buffer and its recbuf alias bound the payload returned by passive and active receives. They default to 65,536 bytes and accept values from 1 byte through 1 MiB. If a received datagram is larger, the delivered payload is truncated and the remainder is discarded.

A wildcard bind creates one family-specific native backing socket for each configured local address in that family. Those backing sockets share one logical OTP socket, receive queue, and controlling process. This preserves the actual destination address internally and lets the stack choose a source address for outbound traffic.

Supported options

The UDP option surface is deliberately finite:

OptionAt openAt runtime
:smolnet_stackrequiredfixed
:inet / :inet6supportedfixed
:binary / :list / :modesupportedsupported
:activefalse, true, :once, or 1..32767supported
:buffer / :recbuf1..1048576supported
:ip / :ifaddr / :portsupportedfixed
:ipv6_v6onlyIPv6 true onlyfixed

Invalid values, options used in the wrong lifecycle phase, and unlisted options return :einval. Selecting the wrong family returns :eafnosupport. Packet framing, send timeouts, ancillary data, multicast, broadcast, OS file descriptors, and raw socket options are not supported.

Network and lifecycle errors use OTP-style atoms such as :enetunreach, :eaddrinuse, :emsgsize, :closed, and :enetdown. One read and one write may progress independently; competing operations in the same direction return :busy.

If a socket's stack fails, a call pending on the socket returns {:error, :enetdown}, and an active socket's owner receives {:udp_error, socket, :enetdown}. A stack fails when it crashes, and when its link dies under link_down: :stop. Calls pending when SmolNet.stop_stack/1 stops the stack, or when its supervisor shuts it down, fail with :closed. Under link_down: :mark_down or {:notify, pid} the stack keeps running without its link, so pending calls keep waiting until they time out or the stack is stopped.