Skip to content

udx-go

Skill udx-go. Use when writing or debugging Go code on go-udx (import github.com/stephanfeb/go-udx, package udx): Listen, Dial, Multiplexer, Connection and Stream over UDP, stream flow control, half-close, deadlines, idle timeouts, MockClock tests, or interop with dart_udx and js-udx over UDX wire v3.

SKILL.md (Claude Code, Codex, Cursor) · udx-go.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- udx-go

go-udx is the Go implementation of UDX, a QUIC-inspired reliable transport over UDP: independent ordered byte streams per connection, CUBIC congestion control with pacing, RFC 9002 loss recovery, per-stream flow control and path MTU discovery. It is the wire authority for UDX v3: dart_udx and js-udx are checked against it. It has no encryption. To run libp2p over UDX in Go, use go-libp2p-udx-transport (skill go-libp2p-udx) instead of using go-udx directly.

Terminal window
go get github.com/stephanfeb/go-udx@v0.3.0
import udx "github.com/stephanfeb/go-udx" // package name is udx

go.mod declares go 1.23.0; the only direct dependency is golang.org/x/net (batched recvmmsg/sendmmsg on Linux).

Multiplexer owns one net.PacketConn and routes datagrams by destination connection ID. Connection is one peer. Stream is an io.ReadWriteCloser.

mux, err := udx.Listen("127.0.0.1:9000")
if err != nil { return err }
defer mux.Close()
for {
conn, err := mux.Accept(ctx)
if err != nil { return err } // udx.ErrConnectionClosed after mux.Close
go func(c *udx.Connection) {
for {
s, err := c.AcceptStream(ctx)
if err != nil { return }
go func(s *udx.Stream) {
io.Copy(s, s) // returns at the peer's FIN (io.EOF)
s.Close() // our FIN
}(s)
}
}(conn)
}
mux, err := udx.Listen("127.0.0.1:0") // also accepts, if you want
if err != nil { return err }
defer mux.Close()
raddr, _ := net.ResolveUDPAddr("udp4", "127.0.0.1:9000")
conn, err := mux.Dial(ctx, raddr) // sends the SYN, does not wait for a reply
if err != nil { return err }
defer conn.Close()
s, err := conn.OpenStream(ctx)
if err != nil { return err }
if _, err := s.Write([]byte("hello")); err != nil { return err } // first Write carries the stream SYN
s.CloseWrite() // FIN; reading continues
reply, err := io.ReadAll(s) // ends at the peer's FIN

udx.Dial(ctx, "host:port") is a shortcut that binds a fresh socket and its own Multiplexer; see Gotchas before using it in long-lived code.

s.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := s.Read(buf)
if errors.Is(err, udx.ErrDeadlineExceeded) { /* retry or give up */ }
s.SetWriteDeadline(time.Now().Add(5 * time.Second)) // Write blocks on flow control
s.Reset(42) // RESET_STREAM; peer's Read/Write get udx.ErrStreamReset
conn.CloseWithError(0x100, "bye") // CONNECTION_CLOSE with an application code
conn.MaxDatagram() // 1200 until PMTUD validates more (up to 1472)
conn.StreamCount()
conn.State() // udx.ConnStateEstablished, ...
conn.RemoteAddr() // follows a peer that a NAT moved, once it answers PATH_CHALLENGE
mux.ConnectionCount()
  • Listen(addr string, opts ...Option) (*Multiplexer, error): binds "udp" on addr.
  • Dial(ctx, addr string, opts ...Option) (*Connection, error): new socket + multiplexer per call.
  • NewMultiplexer(conn net.PacketConn, clk Clock) *Multiplexer: wrap your own socket.
  • WithClock(c Clock) Option: inject a clock (tests).
  • (*Multiplexer).Accept(ctx), Dial(ctx, net.Addr), Close(), Addr(), ConnectionCount().
  • (*Connection).OpenStream(ctx), AcceptStream(ctx), Close(), CloseWithError(code, reason).
  • (*Connection).Ping(ctx): sends a PING, fire-and-forget (does not wait for the ACK).
  • (*Connection).InitiatePathMigration(ctx, newAddr): PATH_CHALLENGE, 5 s timeout.
  • (*Connection).LocalAddr(), RemoteAddr(), State(), MaxDatagram(), StreamCount(), InboundDropped().
  • (*Stream).Read, Write, Close, CloseWrite, Reset(code), SetReadDeadline, SetWriteDeadline, State(), BufferedBytes(), RecvWindow().
  • Errors: ErrConnectionClosed, ErrStreamReset, ErrStreamClosed, ErrWriteAfterClose, ErrDeadlineExceeded, ErrMaxStreams, ErrUnsupportedVersion; protocol errors are *UDXError{Code, Reason}.
  • Codes: ErrorNoError 0, ErrorInternalError 1, ErrorStreamLimitError 2, ErrorFlowControlError 3, ErrorProtocolViolation 4, ErrorInvalidMigration 5, ErrorConnectionTimeout 6.
  • Constants: SafeDatagramSize 1200, MaxDatagramSize 1472, MaxIdleTimeout 30 s, InitialMaxStreamData 64 KiB, MaxStreamRecvWindow 4 MiB, InitialMaxStreams 100, DefaultCIDLength 8.
  • VersionV3, SupportedVersions, IsVersionSupported.
  • Clock, RealClock{}, NewMockClock(start time.Time) with Advance(d).
  • MetricsObserver: optional hooks for cwnd, RTT and loss.
  1. v3 is a flag day. v3 STREAM frames carry an 8-byte offset where v2 had the length, so a v2 peer would accept garbage. Packets of any other version are dropped, so a mismatch looks like an unreachable peer. v1/v2 in SupportedVersions are for negotiation reports only.
  2. OpenStream sends nothing. The stream’s SYN rides its first Write. A peer blocked in AcceptStream waits until you write, and closing a stream you never wrote to sends a bare FIN that the peer drops. In a protocol where the acceptor speaks first, write something first.
  3. Close() on a Stream is a half-close. It sends FIN with the final size and leaves reading open; CloseWrite() is the same call. The stream is done when both sides have sent FIN. Dart’s UDXStream.close() is a full close and JS’s is end(); do not assume the names match.
  4. udx.Dial leaks its socket. It creates a Multiplexer per call, and Connection.Close only removes the connection from it: the UDP socket and its read and idle goroutines stay. In servers, tools that dial often, and tests, use Listen (or NewMultiplexer) once, mux.Dial per peer, and mux.Close() at the end.
  5. Dials do not wait for the peer. Dial returns once the SYN is sent; a peer that never answers is detected only by the idle timeout (30 s), when the connection closes and blocked calls return ErrStreamReset or ErrConnectionClosed. Put your own timeout on the first read.
  6. Idle connections close after max(30 s, 3 x PTO) without a packet received. The close is silent (no CONNECTION_CLOSE) and resets every stream. go-udx sends no keep-alives; Ping is fire-and-forget. Keep connections busy from above (yamux keep-alive) or write periodically.
  7. The stream limit is per connection lifetime. Closed streams are never removed from the connection’s table, and OpenStream fails with ErrMaxStreams once len(streams) reaches the peer’s MAX_STREAMS (100 by default). Open a new connection for workloads with many short streams.
  8. Accept queues drop when full. Accept and AcceptStream sit on 16-slot channels; when one is full a new connection is dropped (logged) and a new stream is silently not delivered. Keep an accept loop running.
  9. Connection-level flow control is accounted, not enforced (flow_control.go, doc/PENDING_WORK.md section 3). Only per-stream windows bind. Do not “fix” this without also sending MAX_DATA, or the stall moves from 64 KiB to 1 MiB.
  10. Stream windows are absolute offsets. WINDOW_UPDATE carries consumed + window modulo 2^32 and the sender reconstructs the 64-bit value. A receiver that does not Read stops the sender after one window; that is the back-pressure. A blocked writer sends STREAM_DATA_BLOCKED every 500 ms because WINDOW_UPDATE is never retransmitted.
  11. Datagrams start at 1200 bytes (v0.1.4). Carrier tunnels (464XLAT, GTP) drop larger unfragmentable datagrams, which stalled every multi-packet reply. PMTUD (v0.3.0) probes up to 1472 and drops back to 1200 on a black hole (three full-size losses in a row, or three PTOs). Do not raise the base.
  12. Retransmits use fresh sequence numbers and never give up (v0.2+). Recovery is RFC 9002: ACK-driven loss, one PTO per connection, persistent congestion collapses the window. A dead path ends by idle timeout, not by a retry count resetting a stream.
  13. Only data-bearing packets are numbered. STREAM with data, SYN or FIN take a sequence number and are ACKed. go-udx’s own control packets go out with sequence 0 and are not ACKed. A numbered PING or MTU_PROBE from dart or js is ACKed at once (fixed in v0.1.3, fix/ack-pings); older go-udx made their ping() always fail and capped their PMTUD.
  14. Dual-stack sockets and batching. udx.Dial binds an unspecified "udp" socket; batched writes from a dual-stack socket to IPv4 peers were silently lost before fix/dual-stack-batch-writes, and batching is now off on IPv6/dual-stack sockets. Bind udp4 or udp6 explicitly when you control the socket.
  15. Stream ids are local. The dialer opens odd ids, the acceptor even ids. An opener never learns the peer’s id and addresses its stream as 0; a bare FIN or RESET for an unknown stream does not open one, data or SYN does.
  16. Several connections may share one peer address. Routing is by CID, so a hole punch (both sides dialling at once) yields two connections, one each way. dart_udx before 4.0.0 folded them into one and broke the punch.
  17. cmd/interop-server is a raw packet echo, not a UDX endpoint. Its header says v2; do not use it as a reference for the current wire format.
  18. Reordering costs throughput. Heavy reordering (25% of packets late) makes loss detection cut the window about twice as often as needed in all three stacks. netem retransmit percentages are noisy; use the seeded loss_detection_test.go to measure.
  • Loopback pair (from transfer_test.go):
func testPair(t *testing.T) (*udx.Connection, *udx.Connection, context.Context) {
a, _ := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
b, _ := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
server := udx.NewMultiplexer(a, udx.RealClock{})
client := udx.NewMultiplexer(b, udx.RealClock{})
t.Cleanup(func() { client.Close(); server.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
c, err := client.Dial(ctx, server.Addr())
if err != nil { t.Fatal(err) }
s, err := server.Accept(ctx) // returns once the SYN arrives
if err != nil { t.Fatal(err) }
return c, s, ctx
}
  • Move enough data to matter (megabytes, with a slow reader): a 256 KB per-stream stall once reached production because every test moved a few bytes.
  • Always run with -race: go test -race ./....
  • udx.NewMockClock(time.Now()) with Advance drives timers in unit tests; WithClock passes a clock to Listen and Dial.
  • The package tests use a lossy/rebinding UDP proxy (rebind_test.go, pmtud_test.go) and a seeded loss simulator (loss_detection_test.go).
  • Interop: go test -v ./interop/ runs against a Dart peer in interop/dartpeer (skips if dart is not on PATH). Set UDX_BULK_PEER="node <js-udx>/tools/bulk-peer/bulk-peer.mjs" to run the bulk and multi-stream suites against js-udx instead.
  • Never change the wire format, frame type codes or version in go-udx without the matching dart_udx and js-udx change, and regenerate js-udx’s conformance vectors (npm run vectors in js-udx).
  • Never reuse a packet sequence number, and never add a retry cap that resets streams; the idle timeout is the backstop.
  • Never send stream data in datagrams above SafeDatagramSize before PMTUD validates the path.
  • Never block the read loop or hold a stream lock while calling into the connection; the code releases s.mu before sending for that reason.
  • Close every Multiplexer you create, including the hidden one behind udx.Dial.
  • UDX is unauthenticated plaintext. Put Noise (or another secure channel) on top before carrying anything sensitive.

Built by Stephan Februarygithub.com/stephanfebLinkedIn