Skip to content

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

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.

# pubspec.yaml (Dart SDK >=3.0.0 <4.0.0)
dependencies:
dart_udx: ^4.3.0
import 'dart:io';
import 'dart:typed_data';
import 'package:dart_udx/dart_udx.dart'; // the only library; exports everything public

The README shows package:dart_udx/udx.dart. That file does not exist: the library is lib/dart_udx.dart.

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

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();
}

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.

// 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 read
// 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 window
sub.resume();

A reset from the peer arrives as a StreamResetError (with errorCode) on stream.data.

await stream.closeWrite(); // half-close: FIN only
await stream.close(); // full close: FIN, then drops anything unread
await stream.reset(1); // RESET_STREAM with an application code
await conn.closeWithError(UdxErrorCode.noError, 'bye'); // CONNECTION_CLOSE to the peer
await conn.close(); // closes the streams locally, sends no CONNECTION_CLOSE
mux.close(); // closes every connection and the RawDatagramSocket
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.
}
});
// 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 CID
final alive = await conn.ping(timeout: const Duration(seconds: 2)); // true when ACKed
  • UDX(): a context object passed to createSocket and stream factories.
  • UDXMultiplexer(RawDatagramSocket socket, {UdxMetricsObserver? metricsObserver, Clock? clock}): routes by CID.
  • UDXMultiplexer.connections: broadcast Stream<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'): UDXEvent whose data is the new UDXStream.
  • 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: createOutgoing past the peer’s MAX_STREAMS (default 100).
  • UdxVersion.current (3), UdxErrorCode, constants in constants.dart (safeDatagramSize 1200, maxDatagramSize 1472, maxIdleTimeout 30 s).
  • Clock, SystemClock, FakeClock: monotonic time, injectable.
  • UdxLogging: setDefaultLogger(), verbose, info, or set logger.
  1. 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).
  2. The README’s client deadlocks. It awaits handshakeComplete before creating a stream, but nothing is sent until createOutgoing sends the SYN, and the handshake completes only when the peer answers. Create the stream first; awaiting handshakeComplete afterwards is optional (add waits for it, up to packetTimeoutTolerance, 30 s).
  3. Inbound streams are missed without flushStreamBuffer(). The stream that opens a connection is emitted synchronously while the datagram is handled, before any listener on connections has run. Attach on('stream') and then call flushStreamBuffer() once. Calling it again later re-emits streams you already handled.
  4. UDXStream.close() is a full close. It marks both halves closed and closes data, so bytes the peer is still sending are discarded. To finish sending and keep reading, call closeWrite(). Mapping across stacks: Dart closeWrite() = Go Stream.Close()/CloseWrite() = JS stream.end().
  5. UDPSocket.close() sends no CONNECTION_CLOSE. The peer finds out only by its 30 s idle timeout. Use closeWithError(UdxErrorCode.noError, '') when the peer should know at once.
  6. No reader means no flow. Since 3.0.0 the receive window advances only as stream.data delivers 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, or reset it.
  7. setWindow sets a size, not an offset. It advertises bytesConsumed + size and cannot take back credit already granted.
  8. 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).
  9. 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.
  10. 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.
  11. createSocket reuses dials by address. Two callers dialling the same host:port share one connection and one close unless you pass shared: false (4.1.0). Inbound connections are never returned by it.
  12. 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.
  13. 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 use FrameType.code / FrameType.fromCode.
  14. 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.
  15. Timing is monotonic (4.3.0). Everything runs on Clock (a process-wide Stopwatch); UDXPacket.sentTime and friends are Durations on that clock, not DateTime. Do not compare them with DateTime.now().
  16. A dead RawDatagramSocket takes the multiplexer with it (4.2.0). Dart closes a socket on any error; the multiplexer then closes all its connections and connections, and sends nothing more. Watch done/lost and bind a new socket.
  17. 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.
  18. 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”.
  • Loopback pair: bind two RawDatagramSockets on InternetAddress.loopbackIPv4 port 0, wrap each in UDXMultiplexer, dial one from the other (as in test/half_close_test.dart). Close both multiplexers in tearDown.
  • 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()), then clock.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.dart builds ../go-udx/cmd/interop-server and 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-udx dart-interop.test.ts.
  • 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 UDXMultiplexer you 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, socketsByCid or other @visibleForTesting members in application code.

Built by Stephan Februarygithub.com/stephanfebLinkedIn