---
name: udx-dart
description: 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.
triggers: dart_udx, dart-udx, UDXMultiplexer, UDPSocket, UDXStream, createOutgoing, flushStreamBuffer, handshakeComplete, closeWrite, UdxLogging, FakeClock
---

# dart_udx

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

```yaml
# pubspec.yaml (Dart SDK >=3.0.0 <4.0.0)
dependencies:
  dart_udx: ^4.3.0
```

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

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

```dart
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

`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

```dart
// 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
```

### Reading

```dart
// 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`.

### Closing

```dart
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
```

### Surviving a dead socket (mobile)

```dart
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

```dart
// 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
```

### Liveness

```dart
final alive = await conn.ping(timeout: const Duration(seconds: 2)); // true when ACKed
```

## Key concepts / API map

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

## Gotchas

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 `Duration`s 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".

## Testing

- Loopback pair: bind two `RawDatagramSocket`s 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`.

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

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