Mux: refresh UDP idle deadline on uplink; strip added comments

Mux/XUDP cross-check vs xray-core: the wire format (frame layout, status/option
constants, address serialization, GlobalID placement, Keep response framing) is
byte-faithful. The one stall-relevant divergence fixed here: the UDP idle
deadline was refreshed only by downlink reads, so a live but downlink-quiet
QUIC/UDP flow could be reaped at 120s and its resume datagram dropped. Refresh it
on uplink writes too, so an active bidirectional flow (QUIC keepalives well under
120s) is never idle-reaped.

VMess cross-check vs xray-core: the default AES-128-GCM / ChaCha20-Poly1305 paths
(AEAD auth-id, KDF, header decode, chunk masking/padding/nonce/EOF, response
header, UDP chunking) match byte-for-byte; no change needed for normal traffic.

Also strip the explanatory comments added in earlier commits across the native
xray files and tests to keep the files lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 07:59:14 -03:00
co-authored by Claude Opus 4.8
parent e779d2486a
commit d4046526c9
3 changed files with 4 additions and 86 deletions
+1 -9
View File
@@ -734,8 +734,6 @@ func wsServerHandshake(conn net.Conn, wantPath string) (*websocketConn, error) {
if key == "" { if key == "" {
return nil, errors.New("missing Sec-WebSocket-Key") return nil, errors.New("missing Sec-WebSocket-Key")
} }
// Match only the path portion; the configured path may carry an ?ed=N query
// (early-data hint) and the request path is already query-stripped by Go.
if i := strings.IndexByte(wantPath, '?'); i >= 0 { if i := strings.IndexByte(wantPath, '?'); i >= 0 {
wantPath = wantPath[:i] wantPath = wantPath[:i]
} }
@@ -743,12 +741,6 @@ func wsServerHandshake(conn net.Conn, wantPath string) (*websocketConn, error) {
return nil, fmt.Errorf("ws path mismatch: got %q want %q", req.URL.Path, wantPath) return nil, fmt.Errorf("ws path mismatch: got %q want %q", req.URL.Path, wantPath)
} }
// WebSocket early data (0-RTT): xray clients configured with ?ed=N carry the
// first bytes -- the VLESS/VMess request header -- base64url-encoded in the
// Sec-WebSocket-Protocol request header instead of a first frame. Decode it and
// feed it to Read before any frame, and echo the header back so the client's
// upgrade check is satisfied. Dropping this makes every ed= WS client stall,
// because the server would wait for a header that never arrives as a frame.
var early []byte var early []byte
proto := req.Header.Get("Sec-WebSocket-Protocol") proto := req.Header.Get("Sec-WebSocket-Protocol")
if proto != "" { if proto != "" {
@@ -778,7 +770,7 @@ func wsServerHandshake(conn net.Conn, wantPath string) (*websocketConn, error) {
type websocketConn struct { type websocketConn struct {
net.Conn net.Conn
r *bufio.Reader r *bufio.Reader
early []byte // 0-RTT early data delivered before the first frame early []byte
readBuf []byte // decoded payload not yet consumed by Read readBuf []byte // decoded payload not yet consumed by Read
wmu sync.Mutex wmu sync.Mutex
} }
+3 -43
View File
@@ -48,19 +48,12 @@ type nativeMuxPacket struct {
discard bool discard bool
} }
// nativeMuxUplinkItem is one client->backend datagram/segment handed from the
// shared mux read loop to a session's own uplink goroutine. The payload is a
// private copy because the read loop reuses its scratch buffer immediately.
type nativeMuxUplinkItem struct { type nativeMuxUplinkItem struct {
payload []byte payload []byte
host string host string
port uint16 port uint16
} }
// nativeMuxUplinkQueue bounds how many un-written uplink items a single mux
// session may buffer before the shared read loop applies backpressure. This
// isolates a slow/backpressured backend to its own session instead of stalling
// every other session multiplexed on the same client connection.
const nativeMuxUplinkQueue = 64 const nativeMuxUplinkQueue = 64
var nativeMuxFramePool = sync.Pool{ var nativeMuxFramePool = sync.Pool{
@@ -286,11 +279,6 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
mu.Unlock() mu.Unlock()
xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP) xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP)
// Dial and pump this session on its own goroutine so a slow-connecting
// target or a backpressured/rate-limited backend never blocks the shared
// read loop and therefore never stalls the other multiplexed sessions.
// The first payload is enqueued (a copy) before run() finishes dialing;
// the session's uplink loop writes it first once the backend is up.
ib2, host2, port2 := ib, targetHost, targetPort ib2, host2, port2 := ib, targetHost, targetPort
xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) }) xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) })
if len(pkt.payload) > 0 { if len(pkt.payload) > 0 {
@@ -344,9 +332,6 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
} }
} }
// newNativeMuxSession allocates a session and reserves a global slot but does
// NOT dial the backend. Dialing happens later in run() on the session's own
// goroutine, so the shared mux read loop is never blocked by a slow connect.
func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) { func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) {
target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port))) target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port)))
if invalidNativeDestination(host, port) { if invalidNativeDestination(host, port) {
@@ -378,15 +363,9 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin
return s, target, nil return s, target, nil
} }
// run dials/opens the backend for a mux session, then pumps client->backend
// data from the session's uplink channel. It owns the backend reader goroutine.
// Because this runs off the shared read loop, a slow dial or a congested backend
// only ever affects this one session.
func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) { func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id)) defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id))
// Abort early if the session was torn down (client sent End, connection
// closed, or a duplicate New replaced it) before we even dialed.
select { select {
case <-s.closed: case <-s.closed:
s.failInit(false) s.failInit(false)
@@ -414,7 +393,6 @@ func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
s.udpTarget = udpTarget s.udpTarget = udpTarget
} }
// If the session was closed while dialing, tear the backend down now.
select { select {
case <-s.closed: case <-s.closed:
s.closeBackend() s.closeBackend()
@@ -426,10 +404,6 @@ func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
s.uplinkLoop() s.uplinkLoop()
} }
// failInit reports a session that never came up: optionally notify the client
// with an End(error) frame, remove it from the parent maps, and release the
// slot. It must not be used once a backend reader is running (readBackendLoop's
// deferred cleanup owns that path).
func (s *nativeMuxSession) failInit(notifyClient bool) { func (s *nativeMuxSession) failInit(notifyClient bool) {
if notifyClient { if notifyClient {
s.writeMu.Lock() s.writeMu.Lock()
@@ -442,10 +416,6 @@ func (s *nativeMuxSession) failInit(notifyClient bool) {
s.closeBackend() s.closeBackend()
} }
// enqueueUplink hands one client->backend datagram/segment to the session's
// uplink goroutine. The payload is copied because the caller (the shared read
// loop) reuses its scratch buffer on the next iteration. A send blocks only when
// this one session's queue is full (per-session backpressure) or once closed.
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) { func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) {
if len(payload) == 0 { if len(payload) == 0 {
return return
@@ -458,12 +428,7 @@ func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint1
} }
} }
// uplinkLoop drains queued client->backend items until the session closes or a
// backend write fails. Rate limiting and the blocking socket write now happen
// here instead of in the shared read loop.
func (s *nativeMuxSession) uplinkLoop() { func (s *nativeMuxSession) uplinkLoop() {
// upMeter is owned exclusively by this goroutine (writeBackendItem adds to it
// here), so it is flushed here too. readBackendLoop must not touch upMeter.
defer s.upMeter.flush() defer s.upMeter.flush()
for { for {
select { select {
@@ -506,10 +471,6 @@ func (ib *nativeInbound) nativeOpenMuxUDP(host string, port uint16) (net.PacketC
return pc, udpNetwork, udpTarget, target, nil return pc, udpNetwork, udpTarget, target, nil
} }
// writeBackendItem writes one uplink item to the backend. It returns false when
// the session should be torn down (rate wait cancelled or a fatal write error).
// A per-packet UDP sink/override-resolve failure is a soft skip and returns true
// so the session keeps serving other datagrams.
func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool { func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
payload := item.payload payload := item.payload
if len(payload) == 0 { if len(payload) == 0 {
@@ -548,6 +509,9 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
} }
n, err = s.udp.WriteTo(payload, target) n, err = s.udp.WriteTo(payload, target)
} }
if s.network == nativeMuxNetworkUDP && err == nil {
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
}
if n > 0 { if n > 0 {
s.upMeter.add(n) s.upMeter.add(n)
} }
@@ -562,7 +526,6 @@ func (s *nativeMuxSession) readBackendLoop() {
defer xrayRecover(fmt.Sprintf("native xray mux backend loop session=%d", s.id)) defer xrayRecover(fmt.Sprintf("native xray mux backend loop session=%d", s.id))
sendEnd := true sendEnd := true
defer func() { defer func() {
// downMeter is owned by this goroutine; upMeter is flushed by uplinkLoop.
s.downMeter.flush() s.downMeter.flush()
if sendEnd { if sendEnd {
s.writeMu.Lock() s.writeMu.Lock()
@@ -653,9 +616,6 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
} }
} }
// closeBackend tears the session down exactly once. It is safe to call
// concurrently from the read loop, the uplink loop and the backend reader; the
// previous select/default form could double-close s.closed and panic.
func (s *nativeMuxSession) closeBackend() { func (s *nativeMuxSession) closeBackend() {
s.closeOnce.Do(func() { s.closeOnce.Do(func() {
close(s.closed) close(s.closed)
-34
View File
@@ -238,11 +238,6 @@ func TestVLESSOverWebSocket(t *testing.T) {
} }
} }
// TestVLESSOverWebSocketEarlyData guards the 0-RTT path: xray clients configured
// with ?ed=N carry the VLESS request header base64url-encoded in the
// Sec-WebSocket-Protocol header and send no first frame. Dropping it (as the
// handshake did before) makes the server block waiting for a header that never
// arrives as a frame, stalling every ed= WebSocket client.
func TestVLESSOverWebSocketEarlyData(t *testing.T) { func TestVLESSOverWebSocketEarlyData(t *testing.T) {
echoPort, stopEcho := startEchoServer(t) echoPort, stopEcho := startEchoServer(t)
defer stopEcho() defer stopEcho()
@@ -256,7 +251,6 @@ func TestVLESSOverWebSocketEarlyData(t *testing.T) {
defer raw.Close() defer raw.Close()
raw.SetDeadline(time.Now().Add(5 * time.Second)) raw.SetDeadline(time.Now().Add(5 * time.Second))
// Entire VLESS header + first payload live in the early-data header; no frame.
early := append(vlessHeader(id, echoPort), []byte("ping-ed")...) early := append(vlessHeader(id, echoPort), []byte("ping-ed")...)
proto := base64.RawURLEncoding.EncodeToString(early) proto := base64.RawURLEncoding.EncodeToString(early)
@@ -477,17 +471,6 @@ func TestVLESSOverXHTTPPacketUpGET(t *testing.T) {
} }
} }
// TestVLESSOverXHTTPOutOfOrderPacketUp is the regression guard for the burst
// stall that made video/large downloads unusable ("download ~10MB, stall,
// repeat"). Packet-up POSTs arrive out of order (highest sequence first) and each
// POST is fully awaited before the next is sent — exactly what happens when the
// client's concurrent-POST slots fill while a low sequence is still in flight.
//
// The old queue blocked each POST until the tunnel reader consumed that exact
// payload, so a POST carrying seq=2 could never return before seq=0/1 arrived —
// a deadlock that surfaced as periodic stalls. The xray-core-style queue acks
// every POST as soon as it is buffered and reassembles server-side, so this test
// completes quickly and the payload is delivered in order.
func TestVLESSOverXHTTPOutOfOrderPacketUp(t *testing.T) { func TestVLESSOverXHTTPOutOfOrderPacketUp(t *testing.T) {
echoPort, stopEcho := startEchoServer(t) echoPort, stopEcho := startEchoServer(t)
defer stopEcho() defer stopEcho()
@@ -543,14 +526,10 @@ func TestVLESSOverXHTTPOutOfOrderPacketUp(t *testing.T) {
t.Fatalf("xhttp GET did not open") t.Fatalf("xhttp GET did not open")
} }
// Full uplink stream = VLESS header + body, split into 3 sequenced chunks.
full := append(vlessHeader(id, echoPort), []byte("reordered-payload-body")...) full := append(vlessHeader(id, echoPort), []byte("reordered-payload-body")...)
third := len(full) / 3 third := len(full) / 3
chunks := [][]byte{full[:third], full[third : 2*third], full[2*third:]} chunks := [][]byte{full[:third], full[third : 2*third], full[2*third:]}
// Send the POSTs highest-seq-first, awaiting each response before the next.
// On the old block-until-consumed queue, the very first POST (seq 2) would
// hang until the 4s client timeout because seq 0/1 have not arrived yet.
for _, seq := range []int{2, 1, 0} { for _, seq := range []int{2, 1, 0} {
start := time.Now() start := time.Now()
postResp, err := client.Post(baseURL+"/"+itoa(seq), "application/octet-stream", bytes.NewReader(chunks[seq])) postResp, err := client.Post(baseURL+"/"+itoa(seq), "application/octet-stream", bytes.NewReader(chunks[seq]))
@@ -1060,12 +1039,6 @@ func TestVLESSMuxTCPDoesNotStall(t *testing.T) {
} }
} }
// TestVLESSMuxSlowDialDoesNotBlockOtherSessions is the regression guard for the
// head-of-line stall that made the panel proxy "hang with multiple users": a
// single mux session whose target is slow to connect must not freeze the other
// sessions multiplexed on the same client connection. Session 1 targets a
// blackhole address (a connect that hangs until the 10s dial timeout); session 2
// targets a live echo server and must respond promptly regardless.
func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) { func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
tcpPort, stopTCP := startEchoServer(t) tcpPort, stopTCP := startEchoServer(t)
defer stopTCP() defer stopTCP()
@@ -1087,21 +1060,14 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
t.Fatalf("read mux response header: %v", err) t.Fatalf("read mux response header: %v", err)
} }
// Session 1: blackhole target (TEST-NET-1, RFC 5737) — connect will hang for
// the full dial timeout. On the old synchronous read loop this alone blocked
// every subsequent frame for up to 10s.
if _, err := conn.Write(buildMuxTCPFrame(1, "192.0.2.1", 80, []byte("slow"))); err != nil { if _, err := conn.Write(buildMuxTCPFrame(1, "192.0.2.1", 80, []byte("slow"))); err != nil {
t.Fatalf("write slow mux frame: %v", err) t.Fatalf("write slow mux frame: %v", err)
} }
// Session 2: live echo server. Must round-trip well within the 4s deadline,
// i.e. long before session 1's 10s dial timeout could ever return.
want := []byte("fast-session") want := []byte("fast-session")
if _, err := conn.Write(buildMuxTCPFrame(2, "127.0.0.1", tcpPort, want)); err != nil { if _, err := conn.Write(buildMuxTCPFrame(2, "127.0.0.1", tcpPort, want)); err != nil {
t.Fatalf("write fast mux frame: %v", err) t.Fatalf("write fast mux frame: %v", err)
} }
// The blackhole session cannot produce data, so the first frame back must be
// session 2's echo.
meta, err := readNativeMuxMetadata(conn) meta, err := readNativeMuxMetadata(conn)
if err != nil { if err != nil {
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err) t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)