Repository navigation
UDP Socket Design #384
Replies: 3 comments
|
Implemented in #385. The design shipped as written. The three open uncertainties are resolved:
Bind failure reason: Windows readiness backend: CI passes on Linux, macOS, and Windows. The echo test is a single round-trip, not a stress test of Remaining future work: notifier variant, multicast convenience methods, connected UDP mode. |
|
Future work moved to issues:
Closing — everything from this design is either shipped or tracked. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
UDP Socket Design
Problem
Lori covers TCP. It doesn't cover UDP.
On the TCP side, connection logic lives in a plain
classthat the user'sactorholds and delegates to — the enclosing actor. The TCP design needs this split because connection lifecycle is complex: Happy Eyeballs (parallel connection attempts across resolved addresses, picking the first to succeed), SSL handshaking, graceful shutdown, send buffering. UDP is simpler. A socket binds to an address, sends and receives datagrams, and closes. No connection handshake, no graceful shutdown, no send buffer to drain.The class-in-actor split applies to UDP too.
UDPSocketis aclassthe enclosing actor holds. I/O is replaceable for testing through a backend trait — a trait that abstracts the FFI calls, with a type parameter onUDPSocketthat swaps in a fake for tests. A state machine tracks lifecycle. The enclosing actor implements two traits —UDPSocketActorfor event-dispatch boilerplate,UDPLifecycleEventReceiverfor callbacks._on_receivedreturns aReadAction(KeepReadingto continue reading,YieldReadingto pause and yield to the scheduler). UDP has no equivalent of TCP's mute/unmute (datagrams have no backpressure to signal), no send tokens (TCP's mechanism for tracking when sent bytes reach the OS; UDP sends are synchronous and all-or-nothing), and no write-side resubscription (a UDP send that can't go out returnsEWOULDBLOCKand the caller decides what to do).Consumer Sketches
Echo server
Same structural pattern as TCP.
Send-only client
Binds to port
"0"(OS-assigned ephemeral port) and sends one datagram in_on_bound._on_receivedis not overridden; the default returnsKeepReadingand arriving data goes unread.Error handling
send_toreturns a four-variant union (defined below under Send result types).Test with fake backend
The type parameter substitutes a test double for the real backend. No real sockets, no ports, no I/O. Same pattern as TCP's backend parameterization.
Type Definitions
UDPAuth
Added to existing
auth.pony. AcceptsAmbientAuthorNetAuth, no sub-split.UDPBackend
Five methods. The names are
bind, notlisten; UDP sockets bind, they don't listen.IPVersionandSocketResultare Lori's existing types.SocketResultis a tri-state (SocketResultOk,SocketResultRetry,SocketResultError);SocketResultDecodermaps FFI return codes to it.recvfromreturns the sender address as part of its return tuple rather than through an out-parameter. With an out-parameterfrom: net.NetAddress tag, fake backends would have to use@memcpyFFI to fill in the address, which defeats the point of a testable backend trait.sendtoreturnsSocketResultonly, no byte count. POSIX datagramsendtois all-or-nothing; the count is always 0 ordata.size(), so returning it adds nothing.UDPRuntimeBackend
The real backend. Wraps ponyc's UDP FFI calls through
SocketResultDecoder.State machine
Three states.
_UDPNonehandles the dispose/init race: ifdispose()arrives before_finish_initialization(),_UDPNone.close()transitions to_UDPClosed, and_finish_initialization()checks for_UDPClosedand returns early. This matches TCP's guard attcp_connection.pony:1917-1918.Socket options are routed through the state machine for consistency with TCP.
The three state classes:
_UDPNone: Pre-initialization.event_notifyandread_againcall_Unreachable().send_toreturnsSendToNotOpen.closetransitions to_UDPClosed. Socket option methods return error values._UDPOpen: Bound and active.event_notifydispatches I/O.send_todelegates to_do_send_to.closeunsubscribes, closes the fd, fires_on_closed.read_againresumes reading. Socket option methods delegate to FFI._UDPClosed: Terminal.event_notifyhandles onlyAsioEvent.disposable. Everything else is a no-op or returnsSendToNotOpen/ error values._UDPNoneexists only for the dispose/init race — the window between construction (which schedules_finish_initializationas a later behavior) and when that behavior runs, during whichdispose()can arrive first. Without_UDPNone, UDP has two meaningful states — open and not-open — and a bool flag would suffice, matchingTCPListener._listening. The state machine is a better fit because it matchesTCPConnection's pattern, accommodates future states (DTLS, connected UDP), and matches the convention in AGENTS.md.Receive error handling
UDP
recvfromreturns transient errors that are not fatal to the socket: ECONNREFUSED from ICMP, ENOMEM, EHOSTUNREACH from a priorsendto. Unlike TCP, where a recv error means the connection is broken, UDP errors are per-datagram.SocketResultErrorfromrecvfromdoes not close the socket. The read loop stops reading for this turn, yields to the scheduler, but the socket stays open. The effect is a brief pause, not a server outage.Fatal socket errors (EBADF, ENOTSOCK) mean the fd is already invalid. The OS will not deliver further readiness events, so no explicit close-on-error logic is needed.
Send result types
Four variants.
SendToFailureis a sub-union of the three failure cases so callers can match onSendToOkvsSendToFailurewithout listing every failure variant.UDPLifecycleEventReceiver
_on_boundand_on_bind_failure, not_on_listening/_on_listen_failure— UDP binds, it doesn't listen._on_bind_failurehas no default; ignoring bind failure is never correct._on_receiveddefaults toKeepReading. Send-only sockets use this default and never override it. The footgun: if your socket receives data and you don't override_on_received, datagrams are silently discarded. Document this.UDPSocketActor
UDPSocket
Constructors:
create(auth, host, port, enclosing, ler, read_buffer_size, ip_version)andnone()._max_datagrams_per_turndefaults to a sensible ceiling (e.g. 256). The byte budget alone is not sufficient to limit iteration count for UDP: a flood of 1-byte datagrams stays well within the byte ceiling while the loop runs thousands of times, leaving other actors unscheduled.Public API:
send_to(data: ByteSeq, to: net.NetAddress box): SendToResultclose()local_address(): net.NetAddressread_again()is_open(): Boolis_closed(): Boolgetsockopt,getsockopt_u32,setsockopt,setsockopt_u32,get_so_rcvbuf,get_so_sndbuf,set_so_rcvbuf,set_so_sndbufWindows
ifdefguards on fd close, matching TCP.Internal Specifications
The methods below have behavior that diverges from TCP or involves subtleties worth specifying. The remaining internal methods (
_do_send_to,_do_close,_do_read_again,_set_state,_destroy_event) are mechanical delegates matching TCP's pattern._event_notifySimpler than TCP's version. No timers, no foreign events, no Happy Eyeballs stragglers. One event per socket. The
event != _eventguard catches stale events from a previous socket lifecycle; they are destroyed if disposable, otherwise ignored._pending_reads_pending_readsis the read loop onUDPSocket, called from_UDPOpen.event_notifywhen the event carries a readable flag. It is bounded by two budgets:_read_buffer_size, matching TCP._max_datagrams_per_turn).Both are necessary. The byte budget limits scheduler time on large datagrams. The datagram count ceiling limits how many tiny datagrams one turn processes within the byte ceiling.
After each
_on_receiveddelivery, checkis_closed(). If the user calledclose()from within_on_received, stop immediately. UDP has no_Closingstate; whenclose()returns, the fd is already closed.On
SocketResultErrorfromrecvfrom: stop reading for this turn. Do not close the socket. The socket stays in_UDPOpen.On
SocketResultRetryfromrecvfrom: no more data available, stop reading.On budget exhaustion: send
_read_againto the enclosing actor to yield to the scheduler and resume in a later behavior.YieldReadingfrom_on_received: stop reading for this turn, send_read_again. Unlike TCP, yielding does not cause backpressure. Datagrams that arrive while yielded may be dropped by the kernel._finish_initializationChecks for
_UDPClosedbefore proceeding. Ifdispose()arrived before the initialization message, the socket is already closed; return early. This matches TCP's guard attcp_connection.pony:1917-1918.Design Decisions
Rationale for each decision is given inline in the sections above. This is the list for reference.
UDPBackendtrait (not merged with TCP).setsockopt.ReadAction._on_receiveddefaults toKeepReading._on_bind_failurehas no default.SendToFailuresub-union.DefaultReadBufferSize(16384 bytes).from: net.NetAddress valin_on_received.to: net.NetAddress boxinsend_to.recvfromerrors do not close the socket._finish_initializationguards against_UDPClosed.close()during_on_receivedstops the read loop immediately.sendtoreturnsSocketResultonly, no byte count.Open Uncertainties
Windows readiness backend and UDP. The ponyc Windows backend uses
ProcessSocketNotifications. Whether this works correctly with UDP sockets needs verification.pony_os_recvfromandSocketResult. Confirm the FFI return maps cleanly to theSocketResulttri-state, and that zero-length datagrams (valid in UDP) are not mapped toSocketResultError.Bind failure reason type. Whether
_on_bind_failureshould take a reason parameter. Deferred for now; TCP's_on_listen_failuretakes none either.Future Work
Notifier variant. The TCP side includes a notifier pattern alongside the lifecycle-receiver pattern. The notifier wraps the callbacks in a separate object that the actor passes in, rather than implementing the callbacks on the actor itself. We should add the same option for UDP.
Multicast convenience methods. Multicast is available through
setsockoptbut has no convenience methods yet. We can add them for common multicast operations when there's demonstrated need.Connected UDP mode. Calling
connect()on a UDP socket restricts it to a single peer, enablingsend/recvinstead ofsendto/recvfromand filtering incoming datagrams by source address. A natural extension, but not needed for the initial implementation.All reactions