Udp

Non-blocking UDP sends and task-based batched receives.

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!.

Send from update!; receive from a task:

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

update! = |model, input, io| {
    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 })
}

receive! parks until one datagram arrives, then drains an arrival-ordered bounded batch. Start the next receive task after that message arrives.

The operating-system socket buffer retains datagrams between receives and may drop them silently on overflow. UDP does not guarantee delivery, ordering, or uniqueness.

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.

An app binds only the ports it declared with UdpBind and sends only to the peers it declared with UdpPeer; UdpAny lifts both limits. A port or peer outside the declarations is PermissionDenied, and using Udp with no UDP declaration at all stops the app as a programmer error. Declaring a peer also permits an ephemeral bind (port 0). Ports below 1024 usually need operating-system privileges, and report AccessRefused 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 hold: 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.

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, AccessRefused, 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. PermissionDenied is a port the app did not declare, and AccessRefused is the operating system refusing the bind.

SendError : [InvalidAddress, TooLarge, WouldBlock, Unreachable, AccessRefused, 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 sending faster than the network link can transmit. 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. PermissionDenied is a peer the app did not declare, and AccessRefused is the operating system refusing the send.

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 : Socket, 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 return 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.

Network

Udp.Network :: # (opaque)

Opaque UDP authority supplied by App.Io, scoped by the app's declared UdpBind, UdpPeer, UdpLoopback, and UdpAny entries.

for_host : Authority -> Network

Private platform construction; no application can manufacture the argument.

bind! : Network, 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!.