Contents

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:

Three app connections become three session IDs inside one VLESS connection
app/proxyman/outbound/handler.go common/mux/client.go

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.

common/mux/frame.go common/mux/server.go common/mux/writer.go

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.

common/mux/session.go

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.

Don't

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.

Do

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.

common/xudp/xudp.go common/mux/server.go