Mux & XUDP
Multiplexing many streams over one connection — and why VLESS/TCP needs it.
Xray tunnels are one-to-one by default: every connection your apps open becomes its own connection to the proxy server. Mux.Cool folds many of those sub-streams into a single tunnel connection, and XUDP extends the same framing so UDP flows (QUIC, games, calls) ride inside it too. This page shows what actually crosses the wire.
Without mux, every app connection is a new tunnel connection
A VLESS connection is not multiplexed — when mux is off, Handler.Dispatch in app/proxyman/outbound/handler.go hands each app connection straight to h.proxy.Process,
which dials the server anew. Load a page with 30 resources over VLESS/TCP+TLS and your client
performs 30 TCP handshakes and 30 TLS handshakes, each one a fresh flow for a censor’s
netflow logs to count and correlate.
With "mux": { "enabled": true }, the same dispatch path detours into mux.ClientManager,
which frames all those streams onto tunnel connections it already has open:
Mux is not a transport — it is a request to a fake address
The mux tunnel is an ordinary VLESS request whose destination happens to be v1.mux.cool:9527. DialingWorkerFactory.Create dials the outbound proxy toward that
hardcoded domain (muxCoolAddress in client.go); on the server, mux.Server.Dispatch compares the destination address against it (dest.Address != muxCoolAddress — the port is
never checked) and — instead of connecting anywhere — spawns a ServerWorker that reads
frames and dispatches each session to its real target. Any protocol that can carry a
destination can carry mux, unchanged.
Inside the connection, every frame starts with metadata (quoted from frame.go):
/*
Frame format
2 bytes - length
2 bytes - session id
1 bytes - status
1 bytes - option
1 byte - network
2 bytes - port
n bytes - address
*/ Status New (0x01) opens a sub-stream and carries the real target address; Keep (0x02)
carries data for an existing session ID; End (0x03) closes one sub-stream without touching
the others; KeepAlive (0x04) keeps the tunnel warm. Stream data is chopped into chunks of at
most 8 KiB (Writer.WriteMultiBuffer splits at 8*1024), so one greedy download cannot starve
the other sessions for long.
Concurrency decides when a second tunnel is dialed
concurrency is not a pool size — it is the per-connection session cap. IncrementalWorkerPicker reuses the first worker that isn’t full and only dials a new tunnel
connection when every existing one is at its limit. A worker is “full” when it holds concurrency live sessions (default 8 when you write 0) or has ever carried 128 sub-streams
total (MaxConnection: 128, hardcoded in handler.go). Idle workers self-destruct: a client
worker with no sessions across two ticks of its 16-second timer closes the underlying
connection (CloseIfNoSessionAndIdle).
{
"protocol": "vless",
"mux": {
"enabled": true,
"concurrency": 8,
"xudpConcurrency": 16,
"xudpProxyUDP443": "reject"
}
} concurrency: -1 disables mux for TCP entirely while xudpConcurrency keeps a separate
worker pool just for UDP — Handler.Dispatch routes UDP traffic to h.xudp so packet flows
never queue behind bulk TCP downloads inside the same tunnel connection.
XUDP lets a UDP session outlive the connection it was born on
XUDP tags each UDP flow with a GlobalID so the server can re-attach it to a new tunnel after
a reconnect. xudp.GetGlobalID computes an 8-byte keyed BLAKE3 hash of the client-side
source address (key: 32 random bytes per process, overridable via XRAY_XUDP_BASEKEY) — but
only for UDP entering through a real proxy inbound (socks, dokodemo-door, shadowsocks, tun, wireguard) with cone NAT enabled; everything else gets an all-zero ID, which handleStatusNew “MUST ignore”. The client embeds it in the New frame. The server keeps XUDPManager.Map[GlobalID] → session:
when a New frame arrives with a known GlobalID, handleStatusNew detaches the session from
its dead mux connection and splices the existing outbound UDP socket onto the new one (“XUDP
hit”). Closed XUDP sessions linger for one minute (Expiring status) before a cleanup
goroutine drops them.
That preserved socket is the point: your NAT mapping — and therefore your QUIC connection or
game session — survives a tunnel reconnect. And because Keep frames for UDP carry a
destination address per packet (FrameMetadata.WriteTo writes b.UDP for followup frames),
one session reaches many peers: full-cone NAT through the tunnel.
One caveat, straight from the config defaults: xudpProxyUDP443 is "reject" unless you say
otherwise, so QUIC to port 443 is refused and browsers fall back to TCP — where mux (or
XTLS-Vision, unmuxed) handles it.
Turn mux on globally and forget it
Mux caps each tunnel at concurrency sessions and interleaves everything through one TCP
stream — one lost packet stalls all sessions on that connection, and latency-sensitive UDP
queues behind bulk transfers.
Split TCP and UDP pools deliberately
Keep concurrency modest for browsing, set xudpConcurrency so UDP gets its own tunnel
connections, and route traffic that hates head-of-line blocking through a separate outbound
with "concurrency": -1.