SCUA

How-to

Send datagrams with UDP

UDP does not deliver your packet. Not "usually delivers", not "delivers unless something is wrong" — there is simply no promise. A datagram arrives, or it doesn't, or it arrives twice, or it arrives out of order, and nothing tells you which. Every other page in these docs describes a thing that works. This one describes a thing that is allowed to fail silently, on purpose, and explains why you'd want that.

UDP is also not encrypted. Anyone on the path can read and modify your datagrams. If you need confidentiality, encrypt the payload yourself or run behind something that does — SCUA will not do it for you, and will not pretend to.

Both of those are the deal. Take it deliberately, or use net.dial (TCP) and http instead.

#Why anyone wants this

Because sometimes a late packet is worse than a lost one.

TCP guarantees delivery and order, and it pays for that with head-of-line blocking: one lost segment stalls every byte behind it while it's retransmitted. For a file, that's exactly right. For a game, it's a disaster — a position update from 200 ms ago is worthless, the world has moved on, and TCP is making you wait for it before it will hand you the fresh one.

UDP lets you drop the stale packet and use the new one. That's the whole trade. It's why games, DNS, QUIC, and every gossip protocol are built on it.

#The grant

Sending datagrams is its own capability, and --allow-net never implies it:

scua --allow-udp=1.1.1.1:53 resolve.scua

The grant names addresses, not just "yes". That's deliberate, and it's the most important thing on this page:

A UDP grant that's only an on/off switch has, by construction, granted "may send packets to any host on the internet." That isn't a capability, it's a weapon. So the destination is part of the grant.

Grant Means
--allow-udp=1.1.1.1:53 that address, that port
--allow-udp=1.1.1.1 that address, any port
--allow-udp=10.0.0.0/8:7946 that subnet, that port
--allow-udp (bare) any peer — trusted hosts only
--allow-udp + --allow-serve=ADDR:PORT additionally: may open a port and hear from strangers

Host names are resolved first, then the resulting address is checked. So a name that resolves outside your allowlist is refused — a name-matching allowlist couldn't do that.

#Talking to a server (udp.connect)

A connected socket is locked to one peer: you may only send there, and datagrams from anyone else are discarded. This is the DNS-client, QUIC-client, game-client shape.

import udp
import bytes

fn ask_once(sock, query)
  udp.send(sock, query)?
  match udp.recv(sock, { timeout_ms = 1000 })
    Ok(dg) -> return Ok(dg.data)
    Error(e) -> return Error(e)
  end
end

-- Retrying is not the error path. It IS the path. Nothing will tell you your packet was lost.
fn ask(sock, query, attempts)
  let i = 0
  while i < attempts do
    match ask_once(sock, query)
      Ok(reply) -> return Ok(reply)
      Error(e) -> i = i + 1
    end
  end
  return Error("no answer after retries")
end

let sock = udp.connect("1.1.1.1", 53)?
match ask(sock, my_query, 3)
  Ok(reply) -> print(`got {len(reply)} bytes`)
  Error(e) -> print(`resolver never answered: {e}`)
end
udp.close(sock)

Note there is no ? on udp.recv inside ask_once — a timeout is an ordinary outcome here, not an exception. See examples/dns_query.scua for a complete, real DNS query.

#Running a server (udp.bind)

udp.bind hears from anyone on the internet. That's listen authority, so it needs both --allow-udp and --allow-serve — two facts, two grants, and granting one never smuggles in the other:

scua --allow-udp --allow-serve=127.0.0.1:9010 echo.scua
let s = udp.bind("127.0.0.1", 9010)?
match udp.recv(s, { timeout_ms = 10000 })
  Ok(dg) -> udp.reply(s, dg.peer, dg.data)
  Error(e) -> print(`recv: {e}`)
end

#dg.peer is a handle, not an address

You can read it — udp.peer_addr(dg.peer) gives you "10.0.0.7:54321" for logs — but you cannot forge one. That distinction is doing real work:

  • udp.reply(sock, peer, data) answers someone who reached you. It needs no allowlist entry, because the address came from the network rather than from your script. (Same as writing back on an accepted TCP connection, which needs no --allow-net.)
  • udp.send_to(sock, host, port, data) sends somewhere you named. That's authority, so it is allowlist-checked.

#The 3× rule (why your big reply got refused)

If you reply to a peer with substantially more data than it sent you, you'll get:

anti-amplification limit: a peer may be sent at most 3x the bytes it has sent us

This is not a bug. Here's the attack it stops:

  1. An attacker sends you a tiny datagram, forging the victim's address as the sender. UDP has no handshake, so you cannot tell.
  2. You answer with something big.
  3. You just sent that traffic to the victim — and so did every other server the attacker sprayed.

That's a reflection/amplification DDoS, and it's why memcached-over-UDP (51,000× amplification) was effectively deleted from the internet. SCUA caps every peer at 3× the bytes it has actually sent you (the rule QUIC uses, RFC 9000 §8), so a spoofed source earns the victim almost nothing. A peer that has sent you nothing can be sent nothing.

If you need to return more data than that, you need the peer to prove it's really there first — send a small challenge, make it echo the value back, and only then send the big payload. That's exactly what QUIC's Retry token does.

#Size: keep it under 1200 bytes

The default payload cap is 1200 bytes, and going over is a loud Error, never a silent truncation. That number isn't arbitrary: it's QUIC's minimum, and it sits under the smallest MTU you'll meet in the wild, so a datagram that size crosses essentially any path without being fragmented.

Fragmentation matters more than it sounds. A fragmented datagram is all-or-nothing — lose one fragment and the whole thing is gone — so fragmenting multiplies your loss rate exactly when you can least afford it. If you have more than 1200 bytes to say, send several datagrams and reassemble them yourself, or use TCP.

Raise it with --udp-max-payload=N if you know your path.

#In a hot loop, use udp.recv_batch

udp.recv gets you one datagram. That's the right shape for a request/response exchange — a DNS query, a health probe — where you send one thing and wait for one answer.

For a receive loop (a game server, a gossip node, anything draining a stream of packets), use udp.recv_batch:

match udp.recv_batch(s, 64, { timeout_ms = 1000 })
  Ok(batch) -> for dg in batch do
    handle(dg.peer, dg.data)
  end
  Error(e) -> print(`recv: {e}`)
end

It waits for one datagram, then takes everything else already queued, up to max (capped at 64). It does not wait for the batch to fill — that would trade away the latency you chose UDP for in the first place.

Why it matters, with numbers. Under --io=async every udp.recv costs a thread handoff (~260 µs), which caps a one-at-a-time receive loop at roughly 3,500 datagrams/s — that's below what a 64-player 60 Hz game server needs (3,840/s). recv_batch pays that handoff once for the whole burst and clears 13,600/s. On the blocking platform it's 42,000/s versus 143,000/s. Same correctness either way — same peer handles, same 3× reply rule — just far less overhead per packet.

Rule of thumb: one exchange → recv. A stream → recv_batch.

#Blocking or async?

By default a udp.recv blocks the worker, exactly as net.accept does. That's fine for a single-purpose tool, and it's the fastest path if the process has nothing else to do.

Pass --io=async and a receive inside an actor handler parks instead: the wait moves off the interpreter thread, so other partitions keep running while you're waiting for a packet. The script doesn't change — no async, no await, no coloring. Only the platform does.

#Things that will bite you

  • Error(interrupted) means the partition was snapshotted mid-wait. For UDP this is indistinguishable from a dropped packet, so handle it the same way — which usually means "do nothing special." This is the one place unreliability is a gift: the snapshot rule costs you nothing.
  • Build payloads with .. — on bytes, .. is binary concatenation, so payload = payload .. chunk does what you'd expect. It will not mix bytes with text, though: b"id=" .. "42" is a loud error, not a conversion. Convert explicitly with bytes.from_string / bytes.to_string.
  • There is no "connection", so nothing will ever tell you a peer went away. If you need to know, you have to ask (a heartbeat) and time out. That's your job, not the protocol's.

#Where this doesn't work

In a browser you cannot open a raw UDP socket. Not from SCUA, not from JavaScript, not from anything — the sandbox forbids it, for exactly the amplification reasons above. import udp is simply not available in a wasm build.

The browser's real answer is WebTransport datagrams (unreliable, unordered, over QUIC), which a host can wire up behind udp.connect. What a browser can never do is udp.bind, because a web page cannot be a server — which is already true of net.listen and isn't a new limitation.

#See also

  • examples/udp_echo.scua — a bound echo server, with the reply rule explained inline
  • examples/dns_query.scua — a real DNS query, by hand
  • Fetch over HTTP — when you want delivery guarantees, use TCP
  • Serve HTTP requests — the serve grant, which udp.bind also needs