SmolNet can back Erlang's :gen_tcp and :inet APIs. This is the best interface for code that already expects OTP socket conventions such as active mode, packet framing, controlling-process ownership, and OTP-style error atoms.

Select the SmolNet backend

Every socket needs two SmolNet-specific options:

  • {:tcp_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.Tcp:inet
IPv6SmolNet.Inet6.Tcp:inet6

For example, these are passive binary IPv4 options:

options = [
  {:tcp_module, SmolNet.Inet.Tcp},
  {:smolnet_stack, stack},
  :inet,
  :binary,
  {:active, false},
  {:packet, :raw}
]

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

A complete loopback example

SmolNet.Loopback is useful for examples and tests because it sends the stack's packets straight back to the same stack:

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

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

{:ok, listener} = :gen_tcp.listen(8080, options)

server =
  Task.async(fn ->
    {:ok, socket} = :gen_tcp.accept(listener, 5_000)
    {:ok, request} = :gen_tcp.recv(socket, 0, 5_000)
    :ok = :gen_tcp.send(socket, ["echo: ", request])
    :ok = :gen_tcp.close(socket)
  end)

{:ok, client} =
  :gen_tcp.connect({127, 0, 0, 1}, 8080, options, 5_000)

:ok = :gen_tcp.send(client, "hello")
{:ok, "echo: hello"} = :gen_tcp.recv(client, 0, 5_000)

:ok = Task.await(server, 5_000)
:ok = :gen_tcp.close(client)
:ok = :gen_tcp.close(listener)
:ok = SmolNet.stop_stack(stack)

The same example is available as examples/loopback.exs in a source checkout.

Clients

Connect with the normal :gen_tcp API. The target address must match the selected family:

ipv6_options = [
  {:tcp_module, SmolNet.Inet6.Tcp},
  {:smolnet_stack, stack},
  :inet6,
  :binary,
  {:active, false}
]

peer = {0xFD00, 0, 0, 0, 0, 0, 0, 2}

{:ok, socket} = :gen_tcp.connect(peer, 443, ipv6_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)

The stack must have a route to the peer, and the application's link must carry the emitted packets to that route. A finite connect or receive timeout is measured in the calling process; the stack owner and native scheduler never block waiting for traffic.

Servers

Listening and accepting use the standard calls:

server_options = [{:backlog, 16} | ipv6_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 backlog defaults to 5 and may be set from 1 through 128. Internally, a listener maintains a bounded pool of native listening sockets. Accepted sockets inherit the listener's mode, active setting, packet framing, buffer sizes, and send-timeout policy, but have independent adapter state.

Closing a listener aborts a pending accept and releases children still waiting in its accepted queue. Children already returned by :gen_tcp.accept/2 remain usable.

Passive and active delivery

With {:active, false}, call :gen_tcp.recv/3. In raw mode, a length of zero returns one currently available chunk. A positive length accumulates until that many bytes arrive, the peer closes, or the operation fails:

{:ok, chunk} = :gen_tcp.recv(socket, 0, 5_000)
{:ok, exactly_32_bytes} = :gen_tcp.recv(socket, 32, 5_000)

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

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

receive do
  {:tcp, ^socket, data} -> handle_data(data)
  {:tcp_closed, ^socket} -> handle_close()
  {:tcp_error, ^socket, reason} -> handle_error(reason)
end

Counted mode sends {:tcp_passive, socket} when its count is exhausted. Active delivery is bounded to 16 native reads or complete logical packets per adapter mailbox turn, so a busy socket yields to other processes.

Only the controlling process receives active messages. Transfer ownership with :gen_tcp.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.

Packet framing and buffers

Supported packet modes are :raw, :line, 1, 2, and 4. The integer modes add or consume an unsigned big-endian length header of that width. packet_size bounds a logical framed packet and defaults to 65,536 bytes. Oversized framed input returns :emsgsize and closes the socket because the stream can no longer be resynchronized.

Raw streams are not message-oriented: packet_size does not cap a raw send or an exact-length raw receive. Large operations are advanced through bounded native reads and writes while the adapter retains their progress.

There are two layers of buffer configuration:

  • buffer is the adapter's Elixir-side receive buffer.
  • recbuf and sndbuf are the native TCP receive and transmit capacities.

buffer defaults to 65,536 bytes, and recbuf and sndbuf to 262,144 bytes (256 KiB), enough for a stream to keep about 20 Mbit/s in flight over a 100 ms round trip. All three accept values up to 1 MiB. Native recbuf and sndbuf have a 1 KiB minimum and are fixed when the socket is created; attempts to change them with :inet.setopts/2 return :einval. They do not tune themselves as Linux's do: size them for the path, larger for a long fast one, or smaller to fit more sockets under a stack's buffer cap (see SmolNet.start_stack/1). Setting recbuf at creation also raises buffer to at least that size unless a later option explicitly lowers buffer.

send_timeout and send_timeout_close control a blocked adapter send. One read and one write may be in progress concurrently; a second operation in the same direction returns :busy.

nodelay is as for :gen_tcp. It defaults to false, which leaves Nagle's algorithm on: a write shorter than a segment waits while an earlier short one is unacknowledged, in Minshall's variant, as on Linux. nodelay: true turns it off, at connect or listen or later with :inet.setopts/2; turning it off sends a write it was holding at once. A socket accepted from a listener takes the listener's nodelay as :gen_tcp.accept/2 returns it.

keepalive is as for :gen_tcp, with Linux's default timing: a connection that has received nothing for 2 hours sends a probe, then one every 75 s while none is answered, and fails with :etimedout once 9 have gone unanswered. Anything the peer sends answers them. It defaults to false, which sends none, so an idle connection whose peer has gone is never noticed, as on Linux without SO_KEEPALIVE. Set it at connect or listen or later with :inet.setopts/2; set later, the 2 hours count from the last packet received. A socket accepted from a listener takes the listener's keepalive as nodelay. The timing is fixed, with no options for TCP_KEEPIDLE, TCP_KEEPINTVL or TCP_KEEPCNT.

A connection that has data unacknowledged, a FIN unacknowledged, or data that its peer's zero window holds back, and hears nothing from its peer for 924.6 s, fails with :etimedout: RFC 5482's user timeout, fixed at how long Linux takes to give up by default (tcp_retries2). Probing a zero window is bounded the same way, since a peer that answers the probes keeps the connection open. A pending :gen_tcp.recv/3 or :gen_tcp.send/2 returns {:error, :etimedout}, an active socket's owner receives {:tcp_error, socket, :etimedout} and then {:tcp_closed, socket}, and the socket closes, as for :econnreset. An idle connection, with nothing outstanding, is never timed out. There is no TCP_USER_TIMEOUT option.

TLS with :ssl

:ssl runs over SmolNet, with either callback module as its transport. Name it in :ssl's cb_info option, and pass the stack, and the family and address options, alongside the TLS options, as for :gen_tcp:

cb_info = {SmolNet.Inet.Tcp, :tcp, :tcp_closed, :tcp_error}

Use SmolNet.Inet6.Tcp and :inet6 for IPv6. Using :ssl covers clients, servers, upgrading a connected socket, certificate verification, and how :ssl reports the errors this guide describes.

:ssl uses three calls a plain :gen_tcp user rarely needs, and both modules provide them:

  • port/1, the socket's local port, as :inet.port/1 returns it.
  • monitor/1 and cancel_monitor/1, which :inet.monitor/1 and :inet.cancel_monitor/1 call for a SmolNet socket. :ssl.listen/2 watches its listener this way. As for an OTP socket-backed :gen_tcp socket, the monitor's message is {:DOWN, ref, :socket, socket, :closed} when the socket closes, or {:DOWN, ref, :socket, socket, :nosock} at once if it was already closed. Any process may monitor a socket, but only the process that set a monitor can cancel it.

A SmolNet socket closes when it is closed, when its owner exits, when a fatal error or send_timeout_close ends it, or when its stack stops. A peer's FIN alone does not close it, since the socket can still send, so a monitor does not trigger until one of those.

Supported options

The inet option surface is deliberately finite:

OptionAt connect/listenAt runtime
:smolnet_stackrequiredfixed
:inet / :inet6supportedfixed
:binary / :list / :modesupportedsupported
:activefalse, true, :once, or 1..32767supported
:packet:raw, :line, 1, 2, or 4supported
:packet_size0..1048576supported
:header0 only0 only
:buffer1..1048576supported
:recbuf / :sndbuf1024..1048576fixed
:nodelaytrue or false (default)supported
:keepalivetrue or false (default)supported
:send_timeout / :send_timeout_closesupportedsupported
:ip / :ifaddr / :portsupportedfixed
:backloglisten only, 1..128fixed
:ipv6_v6onlyIPv6 true onlyfixed

Invalid values, options used in the wrong lifecycle phase, and unlisted options return :einval. Selecting the wrong family returns :eafnosupport. Ancillary data, OS file descriptors, raw socket options, and other packet modes are not supported.

Network and lifecycle errors use the usual OTP-style atoms, including :econnrefused, :econnreset, :etimedout, :enetunreach, :eaddrinuse, :closed, and :enetdown.

If a socket's stack fails, a call pending on the socket returns {:error, :enetdown}, and an active socket's owner receives {:tcp_error, socket, :enetdown} and then {:tcp_closed, socket}. A send that had already queued part of its data returns {:error, {:enetdown, rest}} with the part it did not queue. 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.