udx-dart
Skill
udx-dart. Use when writing or debugging Dart code on dart_udx (import package:dart_udx/dart_udx.dart): UDXMultiplexer, UDPSocket and UDXStream over a RawDatagramSocket, stream back-pressure, half-close, hole punching, testing with FakeClock, or interop with go-udx and js-udx over UDX wire v3.SKILL.md (Claude Code, Codex, Cursor) · udx-dart.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- udx-dart
What it is
Section titled “What it is”dart_udx is the Dart implementation of UDX, a QUIC-inspired reliable transport over plain UDP: many ordered byte streams per connection, CUBIC congestion control, stream and connection flow control, path MTU discovery and connection migration. It speaks UDX wire version 3, the same as go-udx and js-udx. It has no encryption; dart-libp2p puts Noise and Yamux on top. If you are building a libp2p node, use dart-libp2p’s UDX transport instead of driving dart_udx directly.
Install
Section titled “Install”# pubspec.yaml (Dart SDK >=3.0.0 <4.0.0)dependencies: dart_udx: ^4.3.0import 'dart:io';import 'dart:typed_data';import 'package:dart_udx/dart_udx.dart'; // the only library; exports everything publicThe README shows package:dart_udx/udx.dart. That file does not exist: the
library is lib/dart_udx.dart.
Core workflow
Section titled “Core workflow”There are three layers. UDXMultiplexer owns one RawDatagramSocket and
routes datagrams by connection ID. UDPSocket is one connection to one peer
(the name is historical: it is not a UDP socket). UDXStream is one reliable
byte stream on a connection, and implements StreamSink<Uint8List>.
Echo server and client in one process
Section titled “Echo server and client in one process”Future<void> main() async { final udx = UDX();
// Server final serverRaw = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final server = UDXMultiplexer(serverRaw); server.connections.listen((UDPSocket conn) { conn.on('stream').listen((event) { final stream = event.data as UDXStream; stream.data.listen( (chunk) => stream.add(chunk), // echo onDone: () => stream.closeWrite(), // peer sent FIN; end our side ); }); // The first stream arrived in the same datagram that created the // connection, before this listener existed. Replay it. conn.flushStreamBuffer(); });
// Client final clientRaw = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final client = UDXMultiplexer(clientRaw); final conn = client.createSocket(udx, '127.0.0.1', serverRaw.port);
// createOutgoing sends the SYN. Do this before awaiting handshakeComplete. final stream = await UDXStream.createOutgoing( udx, conn, 1, 0, '127.0.0.1', serverRaw.port);
final reply = stream.data.first; await stream.add(Uint8List.fromList('hello'.codeUnits)); print(String.fromCharCodes(await reply.timeout(const Duration(seconds: 5))));
await stream.close(); client.close(); server.close();}Stream ids
Section titled “Stream ids”createOutgoing(udx, socket, localId, remoteId, host, port) takes both ids.
Use an odd localId on the dialing side and remoteId: 0 when you do not
know the peer’s id; go-udx and js-udx never learn the opener’s id and always
address its stream as 0. An acceptor gets the id the opener named if it is
free, otherwise the next free even id (UDPSocket.allocateIncomingStreamId).
Use a different localId for each concurrent stream on one connection.
Writing with back-pressure
Section titled “Writing with back-pressure”// add() waits for congestion-window and flow-control credit. Await it.for (final chunk in chunks) { await stream.add(chunk);}await stream.closeWrite(); // FIN carrying the final size; you can still readReading
Section titled “Reading”// stream.data is single-subscription and cached. Bytes count as consumed// when they are delivered to this listener, and only consumption reopens the// peer's window. A paused or missing listener stalls the sender.final sub = stream.data.listen(onChunk, onError: onError, onDone: onEnd);sub.pause(); // back-pressure: the sender blocks after one windowsub.resume();A reset from the peer arrives as a StreamResetError (with errorCode) on
stream.data.
Closing
Section titled “Closing”await stream.closeWrite(); // half-close: FIN onlyawait stream.close(); // full close: FIN, then drops anything unreadawait stream.reset(1); // RESET_STREAM with an application codeawait conn.closeWithError(UdxErrorCode.noError, 'bye'); // CONNECTION_CLOSE to the peerawait conn.close(); // closes the streams locally, sends no CONNECTION_CLOSEmux.close(); // closes every connection and the RawDatagramSocketSurviving a dead socket (mobile)
Section titled “Surviving a dead socket (mobile)”mux.done.then((err) { if (mux.lost) { // The OS closed the socket (Android does this when it cuts an app's // network). Every connection on it is already closed. Rebind. }});Separate dials to one address
Section titled “Separate dials to one address”// Default: a second createSocket to the same host:port returns the first one,// and closing it closes both callers' connection.final a = mux.createSocket(udx, host, port, shared: false);final b = mux.createSocket(udx, host, port, shared: false); // its own CIDLiveness
Section titled “Liveness”final alive = await conn.ping(timeout: const Duration(seconds: 2)); // true when ACKedKey concepts / API map
Section titled “Key concepts / API map”UDX(): a context object passed tocreateSocketand stream factories.UDXMultiplexer(RawDatagramSocket socket, {UdxMetricsObserver? metricsObserver, Clock? clock}): routes by CID.UDXMultiplexer.connections: broadcastStream<UDPSocket>of inbound connections.UDXMultiplexer.createSocket(udx, host, port, {localCid, remoteCid, isServer, shared = true}): dial.UDXMultiplexer.done/lost: the socket stopped, and whether it stopped by itself.UDXMultiplexer.close(): synchronous; closes connections and the socket.UDPSocket.on('stream'):UDXEventwhosedatais the newUDXStream.UDPSocket.flushStreamBuffer(): re-emits every inbound stream seen so far.UDPSocket.handshakeComplete: completes on the first valid packet from the peer.UDPSocket.maxDatagram: current validated datagram size (1200 until PMTUD raises it).UDPSocket.ping({timeout}),close(),closeWithError(code, reason),remoteAddress,remotePort,isServer.UDXStream.createOutgoing(...): async; registers the stream and sends SYN, MAX_DATA, MAX_STREAMS.UDXStream.add,addStream,closeWrite,close,reset(code),stopReceiving(code),setWindow(size).UDXStream.data,end,drain,closeEvents,done;id,remoteId,bytesRead,bytesWritten,rtt,cwnd,mtu.StreamLimitExceededError:createOutgoingpast the peer’s MAX_STREAMS (default 100).UdxVersion.current(3),UdxErrorCode, constants inconstants.dart(safeDatagramSize1200,maxDatagramSize1472,maxIdleTimeout30 s).Clock,SystemClock,FakeClock: monotonic time, injectable.UdxLogging:setDefaultLogger(),verbose,info, or setlogger.
Gotchas
Section titled “Gotchas”- v3 does not interoperate with v2. The 8-byte STREAM offset sits where a v2 parser reads the length, so packets of any other version are dropped. A mismatch looks like an unreachable peer, not an error. Upgrade both ends together (dart_udx 3.x/4.x with go-udx v0.1+ and js-udx).
- The README’s client deadlocks. It awaits
handshakeCompletebefore creating a stream, but nothing is sent untilcreateOutgoingsends the SYN, and the handshake completes only when the peer answers. Create the stream first; awaitinghandshakeCompleteafterwards is optional (addwaits for it, up topacketTimeoutTolerance, 30 s). - Inbound streams are missed without
flushStreamBuffer(). The stream that opens a connection is emitted synchronously while the datagram is handled, before any listener onconnectionshas run. Attachon('stream')and then callflushStreamBuffer()once. Calling it again later re-emits streams you already handled. UDXStream.close()is a full close. It marks both halves closed and closesdata, so bytes the peer is still sending are discarded. To finish sending and keep reading, callcloseWrite(). Mapping across stacks: DartcloseWrite()= GoStream.Close()/CloseWrite()= JSstream.end().UDPSocket.close()sends no CONNECTION_CLOSE. The peer finds out only by its 30 s idle timeout. UsecloseWithError(UdxErrorCode.noError, '')when the peer should know at once.- No reader means no flow. Since 3.0.0 the receive window advances only
as
stream.datadelivers bytes. A stream nobody listens to stalls its sender after 64 KiB. That is the back-pressure, not a bug; listen to every stream you accept, orresetit. setWindowsets a size, not an offset. It advertisesbytesConsumed + sizeand cannot take back credit already granted.- Idle connections die after 30 s.
max(maxIdleTimeout, 3 x PTO)with no packet received closes the socket and resets its streams silently. Nothing sends keep-alives for you:ping()periodically, or rely on a layer above (Yamux keep-alive in dart-libp2p). - Datagrams start at 1200 bytes (
safeDatagramSize, since 4.2.1). Mobile carrier tunnels (464XLAT, GTP) drop larger unfragmentable datagrams; 1454-byte packets stalled every multi-packet write on such paths. PMTUD (4.3.0) probes up towards 1472 and falls back to 1200 after three full-size losses in a row. Do not raise the base size. - Crossing dials are two connections (4.0.0). In a hole punch each side
dials the other and each SYN becomes its own connection on
connections, as go-udx and js-udx do; libp2p keeps whichever completes. A 3.x peer still folds them into one, so a simultaneous open between 3.x and 4.x can fail: dial one way or upgrade both. createSocketreuses dials by address. Two callers dialling the samehost:portshare one connection and one close unless you passshared: false(4.1.0). Inbound connections are never returned by it.- Several connections from one address are normal. go-udx and js-udx
dial everything from one shared socket; since 3.1.0 each inbound SYN with a
new CID gets its own
UDPSocket. Do not key your own state by address. - Wire codes are not enum indexes. Before 3.1.0, frame types were written
as
FrameType.index, off by one from STOP_SENDING (0x0d) up, so Go and JS rejected Dart’s STOP_SENDING and Dart misread STREAM_DATA_BLOCKED. Always useFrameType.code/FrameType.fromCode. - Control packets are not retransmitted. ACK, WINDOW_UPDATE, RESET_STREAM and STOP_SENDING go out untracked. A blocked writer re-sends STREAM_DATA_BLOCKED / DATA_BLOCKED every 500 ms so a lost update recovers; a lost RESET_STREAM is not re-sent.
- Timing is monotonic (4.3.0). Everything runs on
Clock(a process-wideStopwatch);UDXPacket.sentTimeand friends areDurations on that clock, notDateTime. Do not compare them withDateTime.now(). - A dead
RawDatagramSockettakes the multiplexer with it (4.2.0). Dart closes a socket on any error; the multiplexer then closes all its connections andconnections, and sends nothing more. Watchdone/lostand bind a new socket. - Historical: SEQUENCE_NUMBER_DESYNC_FIX.md describes a v2 bug where unregistered control packets consumed sequence numbers and wedged the receiver. v3 reassembles each stream by byte offset and only data-bearing packets are numbered, so the note explains old logs, not current code.
- A FIN can overtake data. A stream ends when delivered bytes reach the final size carried by the FIN. Never treat FIN arrival as “all data in”.
Testing
Section titled “Testing”- Loopback pair: bind two
RawDatagramSockets onInternetAddress.loopbackIPv4port 0, wrap each inUDXMultiplexer, dial one from the other (as intest/half_close_test.dart). Close both multiplexers intearDown. - Always put
.timeout(...)on futures you await (connections.first, stream completers,data.first), or a lost packet hangs the test run. - Virtual time:
UDXMultiplexer(raw, clock: FakeClock()), thenclock.advance(duration)fires due timers in order (test/clock_test.dart,test/pto_timer_test.dart). - Inject datagrams without a network:
mux.handleIncomingDatagramForTest(bytes, address, port). - Lossy paths: the suite relays datagrams through a dropping proxy
(
test/retransmit_accounting_test.dart,test/small_mtu_path_test.dart). - Logging:
UdxLogging.setDefaultLogger(); UdxLogging.info = true;(off by default). - Run:
dart test.test/interop/go_udx_interop_test.dartbuilds../go-udx/cmd/interop-serverand needs Go and a go-udx checkout beside this repo. - Cross-implementation suites live in the other repos: go-udx
go test ./interop/(drives a Dart peer) and js-udxdart-interop.test.ts.
Red lines
Section titled “Red lines”- Never change the wire format, frame codes or version number in one implementation alone. go-udx is the wire authority; dart, go and js move together.
- Never send or accept v2 packets on a v3 connection, and never “fix” a version mismatch by widening what the receiver accepts.
- Never raise the datagram size above what PMTUD validated, and never send stream data above 1200 bytes before validation.
- Never leave an accepted stream unread and unreset: it pins its sender and its buffers until the idle timeout.
- Close every
UDXMultiplexeryou create; it owns a socket and timers. - UDX is not encrypted or authenticated. Do not send secrets over it without a security layer (Noise in dart-libp2p).
- Do not rely on
addSocket,getSocketsForTest,socketsByCidor other@visibleForTestingmembers in application code.
Reference
Section titled “Reference”- Docs: https://cryptopeer.net/udx/dart/ and the protocol overview https://cryptopeer.net/udx/
- Repo: https://github.com/stephanfeb/dart-udx (CHANGELOG.md, DEVELOPER_GUIDE.md)
- Package: https://pub.dev/packages/dart_udx
- Related skills: udx-go, udx-js, go-libp2p-udx