Udp

UDP sockets, for multiplayer, livecoding, and streaming data.

A socket is bound once and kept in the model. bind! and send! are legal in init!, update!, and tasks, and refused in render!, which draws. receive! waits: it is legal in init!, where it blocks startup, and in tasks, where it parks the task; it is refused in update! and render!.

So sending belongs in update! beside the rest of a frame's work, and receiving belongs in a task:

Msg : [Arrived(List(Udp.Datagram)), ReceiveFailed(Udp.ReceiveError)]

update! = |model, input| {
    socket = model.socket
    if !model.listening {
        Task.spawn!(
            input,
            || match Udp.Socket.receive!(socket, Udp.default_receive) {
                Ok(datagrams) => Arrived(datagrams)
                Err(err) => ReceiveFailed(err)
            },
        )
    }
    dropped = match Udp.Socket.send!(socket, model.peer, position_bytes(input)) {
        Ok({}) => model.dropped
        Err(_) => model.dropped + 1
    }
    answered = !List.is_empty(input.messages)
    Ok({ ..model, dropped, listening: !answered })
}

listening is set from whether this cycle's input answered rather than latched to true, so the next update! starts the next listener. A failed send! is counted rather than propagated: WouldBlock is a full send buffer, an ordinary condition on a busy link, and a position update would rather skip a frame than end the app.

A task delivers exactly one message, so the loop is "receive, answer, and let update! start the next one" rather than a task that loops forever. A task that never returns never delivers anything.

That is also why receive! answers with a whole batch. It parks until the first datagram arrives and then drains what the kernel already has, so one message carries a frame's worth of traffic in arrival order. A receive that answered with a single datagram would cap a respawned listener at one datagram per frame, which is far below what even a small game needs.

Datagrams that arrive while no receive! is pending are not lost: they wait in the operating system's own buffer, and the next receive! returns them immediately. They are lost when that buffer overflows, silently, with no report -- that is the UDP contract, and sequence numbers in the payload are the app's answer to it. Delivery, ordering, and duplication are not promised by the protocol and are not added here.

Framing, retransmission, serialization, discovery, and session state are the app's or a package's. This module moves bytes and nothing else.

Addresses are IPv4 literals. An IPv6 address is InvalidAddress, and there is no name resolution: bind! and send! take a dotted-quad string, never a hostname, because resolving a name waits and neither of these effects does.

This grants the app the network authority the process already has: any port it may bind, any host it may send to. Ports below 1024 usually need privileges, and report PermissionDenied when they are missing. Broadcast and multicast are not enabled.

default_receive : ReceiveConfig

One second, and up to sixty-four datagrams.

The deadline is short enough that a listener respawned every frame keeps its socket's queue drained even when a peer goes quiet, and long enough not to churn tasks at idle.

max_datagram_bytes : U64

The largest payload one datagram may carry: 65535 bytes of IPv4 packet less the 20-byte IP header and the 8-byte UDP header. A send over this is TooLarge.

Sending anywhere near it is unwise on a real network, where anything over the path MTU is fragmented and one lost fragment loses the whole datagram. Real-time protocols stay under about 1200 bytes.

bind! : Address => Try(Socket, BindError)

Bind a socket to a local address.

{ ip: "0.0.0.0", port: 0 } takes any interface and lets the operating system choose the port; the returned socket's local_address reports what it chose. Bind once, in init! or when the app decides to start networking, and keep the socket in the model -- binding per frame would exhaust the eight-socket ceiling and change the port every time.

Legal in init!, update!, and tasks; refused in render!.

Address : { ip : Str, port : U16 }

An IPv4 endpoint. ip is a dotted-quad literal such as "127.0.0.1".

Compare the from of a datagram you received rather than rebuilding it: this is a plain record, so two values are equal when their text is.

Datagram : { from : Address, bytes : List(U8) }

One received datagram, and the address it came from. Reply to from:

reply! : Udp.Socket, Udp.Datagram, List(U8) => Try({}, Udp.SendError)
reply! = |socket, datagram, bytes| Udp.Socket.send!(socket, datagram.from, bytes)
ReceiveConfig : { timeout_ms : U64, max_datagrams : U32 }

Per-receive limits.

timeout_ms is how long to wait for the first datagram; 0 waits forever, which only makes sense in a task the app is content to leave parked until shutdown. max_datagrams caps the batch, and the host clamps it to sixty-four -- past that the rest simply stay buffered for the next receive.

A plain record; build one with { ..Udp.default_receive, timeout_ms: 200 } rather than a chain of with_* calls.

BindError : [InvalidAddress, AddressInUse, AddressUnavailable, PermissionDenied, ResourceLimit, Unavailable]

Why a socket was not bound.

AddressInUse is another socket already holding the port, AddressUnavailable is an address that is not one of this machine's, and ResourceLimit is this platform's own ceiling of eight open sockets. InvalidAddress is a string that is not a dotted-quad IPv4 literal.

SendError : [InvalidAddress, TooLarge, WouldBlock, Unreachable, PermissionDenied, SendFailed, Unavailable]

Why a datagram was not handed to the kernel.

WouldBlock is the send buffer being full: the datagram was not sent, and the app is producing faster than the link can carry. TooLarge is a payload over max_datagram_bytes, refused rather than truncated, because a truncated datagram decodes into wrong data. None of these mean the peer received anything, and no code means it did -- UDP does not report that.

ReceiveError : [Timeout, AlreadyReceiving, ReceiveFailed, Unavailable]

Why a receive produced no datagrams.

Timeout is the deadline expiring with nothing having arrived, which is an ordinary quiet moment rather than a failure. AlreadyReceiving is a second task trying to receive on a socket that already has one parked.

Socket

:= { handle : Handle, local : Address }

An open datagram socket.

The handle is reference counted: copy it freely, and when the last copy goes -- out of the model, out of a task's captures, or at shutdown -- the socket is closed. There is nothing to remember to close.

local_address : Socket -> Address

The address this socket is actually bound to, including the port the operating system chose when bind! was given 0.

send! : Socket, Address, List(U8) => Try({  }, SendError)

Send one datagram. This does not wait for anything: the payload goes to the operating system and the call is over, whether or not any peer is listening. Nothing here reports that a datagram arrived, because UDP does not.

It is a queued effect: the datagram is handed whole to the kernel's bounded send buffer and the call returns at once, with the sending itself happening off the frame thread. WouldBlock is that queue's saturation -- the buffer was full, so this datagram was not taken -- and it is a typed result rather than a wait or a silent drop.

Legal in init!, update!, and tasks; refused in render!.

receive! : Socket, ReceiveConfig => Try(List(Datagram), ReceiveError)

Wait for datagrams, and answer with every one that was ready.

Parks until the first datagram arrives or timeout_ms expires, then returns it together with whatever else is already buffered, in arrival order, up to max_datagrams. Err(Timeout) means nothing arrived in time, which is a quiet moment rather than a failure.

One receive at a time per socket: a second one while another task is parked is AlreadyReceiving.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

stub : Socket

Resource-free socket value for pure tests.

The handle never resolves to an open socket, so every effect made through it fails as Unavailable -- Err(Unavailable) from send! and from receive! -- the same way a call through a closed socket does. It exists for the app that keeps a socket in its model, to let a pure expect build that model. Do not use it to test delivery or resource lifetime.