Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4046526c9 | ||
|
|
e779d2486a | ||
|
|
8f088f7cca |
+23
-2
@@ -734,20 +734,35 @@ 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")
|
||||||
}
|
}
|
||||||
|
if i := strings.IndexByte(wantPath, '?'); i >= 0 {
|
||||||
|
wantPath = wantPath[:i]
|
||||||
|
}
|
||||||
if wantPath != "" && wantPath != "/" && req.URL.Path != wantPath {
|
if wantPath != "" && wantPath != "/" && req.URL.Path != wantPath {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var early []byte
|
||||||
|
proto := req.Header.Get("Sec-WebSocket-Protocol")
|
||||||
|
if proto != "" {
|
||||||
|
if ed, derr := base64.RawURLEncoding.DecodeString(proto); derr == nil {
|
||||||
|
early = ed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sum := sha1.Sum([]byte(key + wsMagicGUID))
|
sum := sha1.Sum([]byte(key + wsMagicGUID))
|
||||||
accept := base64.StdEncoding.EncodeToString(sum[:])
|
accept := base64.StdEncoding.EncodeToString(sum[:])
|
||||||
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
|
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||||
"Upgrade: websocket\r\n" +
|
"Upgrade: websocket\r\n" +
|
||||||
"Connection: Upgrade\r\n" +
|
"Connection: Upgrade\r\n" +
|
||||||
"Sec-WebSocket-Accept: " + accept + "\r\n\r\n"
|
"Sec-WebSocket-Accept: " + accept + "\r\n"
|
||||||
|
if proto != "" {
|
||||||
|
resp += "Sec-WebSocket-Protocol: " + proto + "\r\n"
|
||||||
|
}
|
||||||
|
resp += "\r\n"
|
||||||
if _, err := conn.Write([]byte(resp)); err != nil {
|
if _, err := conn.Write([]byte(resp)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &websocketConn{Conn: conn, r: br}, nil
|
return &websocketConn{Conn: conn, r: br, early: early}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// websocketConn adapts a WebSocket data stream to a net.Conn. Client frames are
|
// websocketConn adapts a WebSocket data stream to a net.Conn. Client frames are
|
||||||
@@ -755,11 +770,17 @@ 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
|
||||||
readBuf []byte // decoded payload not yet consumed by Read
|
readBuf []byte // decoded payload not yet consumed by Read
|
||||||
wmu sync.Mutex
|
wmu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *websocketConn) Read(p []byte) (int, error) {
|
func (c *websocketConn) Read(p []byte) (int, error) {
|
||||||
|
if len(c.early) > 0 {
|
||||||
|
n := copy(p, c.early)
|
||||||
|
c.early = c.early[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
for len(c.readBuf) == 0 {
|
for len(c.readBuf) == 0 {
|
||||||
payload, opcode, err := c.readFrame()
|
payload, opcode, err := c.readFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+3
-43
@@ -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)
|
||||||
|
|||||||
+74
-28
@@ -238,6 +238,80 @@ func TestVLESSOverWebSocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVLESSOverWebSocketEarlyData(t *testing.T) {
|
||||||
|
echoPort, stopEcho := startEchoServer(t)
|
||||||
|
defer stopEcho()
|
||||||
|
_, port, id, stop := newTestInbound(t, "ws", "/vlws")
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
raw, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
early := append(vlessHeader(id, echoPort), []byte("ping-ed")...)
|
||||||
|
proto := base64.RawURLEncoding.EncodeToString(early)
|
||||||
|
|
||||||
|
var keyBytes [16]byte
|
||||||
|
rand.Read(keyBytes[:])
|
||||||
|
key := base64.StdEncoding.EncodeToString(keyBytes[:])
|
||||||
|
req := "GET /vlws HTTP/1.1\r\n" +
|
||||||
|
"Host: test\r\n" +
|
||||||
|
"Upgrade: websocket\r\n" +
|
||||||
|
"Connection: Upgrade\r\n" +
|
||||||
|
"Sec-WebSocket-Key: " + key + "\r\n" +
|
||||||
|
"Sec-WebSocket-Protocol: " + proto + "\r\n" +
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
if _, err := raw.Write([]byte(req)); err != nil {
|
||||||
|
t.Fatalf("handshake write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(raw)
|
||||||
|
statusLine, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read status: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(statusLine, "101") {
|
||||||
|
t.Fatalf("ws handshake not 101: %q", statusLine)
|
||||||
|
}
|
||||||
|
sawProto := false
|
||||||
|
for {
|
||||||
|
line, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read headers: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(line, proto) {
|
||||||
|
sawProto = true
|
||||||
|
}
|
||||||
|
if line == "\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawProto {
|
||||||
|
t.Fatalf("server did not echo Sec-WebSocket-Protocol")
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &testWSConn{Conn: raw, r: br}
|
||||||
|
buf := make([]byte, 0, 32)
|
||||||
|
want := 2 + len("ping-ed")
|
||||||
|
for len(buf) < want {
|
||||||
|
chunk := make([]byte, 64)
|
||||||
|
n, err := ws.Read(chunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ws read (early data dropped?): %v (got %q)", err, buf)
|
||||||
|
}
|
||||||
|
buf = append(buf, chunk[:n]...)
|
||||||
|
}
|
||||||
|
if buf[0] != 0 {
|
||||||
|
t.Fatalf("bad ws vless response: %v", buf[:2])
|
||||||
|
}
|
||||||
|
if string(buf[2:want]) != "ping-ed" {
|
||||||
|
t.Fatalf("ws early-data echo mismatch: got %q", buf[2:want])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVLESSOverXHTTPPacketUp(t *testing.T) {
|
func TestVLESSOverXHTTPPacketUp(t *testing.T) {
|
||||||
echoPort, stopEcho := startEchoServer(t)
|
echoPort, stopEcho := startEchoServer(t)
|
||||||
defer stopEcho()
|
defer stopEcho()
|
||||||
@@ -397,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()
|
||||||
@@ -463,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]))
|
||||||
@@ -980,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()
|
||||||
@@ -1007,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)
|
||||||
|
|||||||
+4
-25
@@ -6,19 +6,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XrayNativeTuning holds the few operator-facing knobs for the in-process native
|
|
||||||
// Xray. Transport-shaping parameters (HTTP/2 flow control, the XHTTP reorder
|
|
||||||
// buffer, mux/UDP socket buffers) are intentionally NOT exposed: they are pinned
|
|
||||||
// to xray-core / Go defaults so they cannot be misconfigured into breakage. Only
|
|
||||||
// safe operational controls remain here: CPU parallelism, a global mux-session
|
|
||||||
// DoS cap, and a packet-level trace toggle for debugging.
|
|
||||||
type XrayNativeTuning struct {
|
type XrayNativeTuning struct {
|
||||||
// RuntimeGOMAXPROCS controls Go CPU parallelism. 0 or negative = all cores.
|
|
||||||
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
|
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
|
||||||
// MuxGlobalSessions caps total concurrent mux child sessions across every
|
|
||||||
// client connection (a DoS guard for the multi-tenant panel). 0 = default.
|
|
||||||
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
|
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
|
||||||
// TracePackets enables very verbose per-packet XHTTP/mux logging. Debug only.
|
|
||||||
TracePackets bool `json:"trace_packets,omitempty"`
|
TracePackets bool `json:"trace_packets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,20 +16,11 @@ const (
|
|||||||
defaultNativeRuntimeGOMAXPROCS = 0
|
defaultNativeRuntimeGOMAXPROCS = 0
|
||||||
defaultNativeMuxGlobalSessions = 32768
|
defaultNativeMuxGlobalSessions = 32768
|
||||||
|
|
||||||
// Fixed transport defaults, aligned with xray-core / Go's net/http2. These are
|
fixedNativeMuxMaxSessions = 128
|
||||||
// deliberately not operator-tunable: wrong values silently break data flow.
|
fixedNativeMuxUDPIdleMS = 120000
|
||||||
fixedNativeMuxMaxSessions = 128 // per-connection mux child-session guard
|
fixedNativeMuxUDPReadBuffer = 256 * 1024
|
||||||
fixedNativeMuxUDPIdleMS = 120000 // mux UDP backend idle cleanup (ms)
|
fixedNativeMuxUDPWriteBuffer = 256 * 1024
|
||||||
fixedNativeMuxUDPReadBuffer = 256 * 1024 // mux UDP socket read buffer
|
|
||||||
fixedNativeMuxUDPWriteBuffer = 256 * 1024 // mux UDP socket write buffer
|
|
||||||
|
|
||||||
// XHTTP: max tracked sessions (DoS guard) and the packet-up reorder buffer.
|
|
||||||
// The per-inbound scMaxBufferedPosts from the config still overrides this.
|
|
||||||
// xray-core's own default is 30 (its client sends POSTs near-in-order), but
|
|
||||||
// other clients (v2rayNG/nekobox/etc.) fan out many concurrent POSTs that can
|
|
||||||
// arrive well out of order; a small buffer then trips the reassembly-too-large
|
|
||||||
// teardown and stalls traffic. Keep a generous default so reordering is
|
|
||||||
// absorbed rather than fatal.
|
|
||||||
defaultNativeXHTTPMaxSessions = 16384
|
defaultNativeXHTTPMaxSessions = 16384
|
||||||
defaultNativeXHTTPBufferedPosts = 512
|
defaultNativeXHTTPBufferedPosts = 512
|
||||||
)
|
)
|
||||||
@@ -84,12 +65,10 @@ func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operator-tunable values.
|
|
||||||
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
|
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
|
||||||
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
|
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
|
||||||
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
||||||
|
|
||||||
// Fixed transport limits (see the const block for rationale).
|
|
||||||
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
||||||
func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
|
func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
|
||||||
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
||||||
|
|||||||
+8
-65
@@ -99,8 +99,6 @@ func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeX
|
|||||||
|
|
||||||
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
||||||
defer xrayRecover(fmt.Sprintf("native xray XHTTP listener inbound=%q addr=%s", ib.tag, ln.Addr()))
|
defer xrayRecover(fmt.Sprintf("native xray XHTTP listener inbound=%q addr=%s", ib.tag, ln.Addr()))
|
||||||
// Match xray-core: let net/http's HTTP/2 use its own defaults for flow
|
|
||||||
// control, stream limits and upload buffers instead of overriding them.
|
|
||||||
h2s := &http2.Server{}
|
h2s := &http2.Server{}
|
||||||
handler := http.Handler(ib)
|
handler := http.Handler(ib)
|
||||||
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
||||||
@@ -164,14 +162,11 @@ func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
mode := ib.normalizedXHTTPMode()
|
mode := ib.normalizedXHTTPMode()
|
||||||
xrayTracef("native xray: xhttp request inbound=%q method=%s proto=%s path=%q host=%q session=%q seq=%q len=%d mode=%s remote=%s", ib.tag, r.Method, r.Proto, r.URL.RequestURI(), r.Host, sessionID, seqStr, r.ContentLength, mode, r.RemoteAddr)
|
xrayTracef("native xray: xhttp request inbound=%q method=%s proto=%s path=%q host=%q session=%q seq=%q len=%d mode=%s remote=%s", ib.tag, r.Method, r.Proto, r.URL.RequestURI(), r.Host, sessionID, seqStr, r.ContentLength, mode, r.RemoteAddr)
|
||||||
|
|
||||||
// Routing mirrors xray-core splithttp hub.go ServeHTTP exactly.
|
|
||||||
if sessionID == "" && mode != "" && mode != "auto" && mode != "stream-one" && mode != "stream-up" {
|
if sessionID == "" && mode != "" && mode != "auto" && mode != "stream-one" && mode != "stream-up" {
|
||||||
http.Error(w, "stream-one mode is not allowed", http.StatusBadRequest)
|
http.Error(w, "stream-one mode is not allowed", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET carries uplink data only when it has a sequence id; every other method
|
|
||||||
// (POST/PUT/PATCH) is always an uplink request.
|
|
||||||
isUplinkRequest := true
|
isUplinkRequest := true
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
isUplinkRequest = seqStr != ""
|
isUplinkRequest = seqStr != ""
|
||||||
@@ -319,9 +314,6 @@ func (ib *nativeInbound) extractXHTTPMeta(r *http.Request, base string) (session
|
|||||||
sessionKey := firstNonEmpty(ib.xhttpSessionKey, defaultXHTTPMetaKey(sessionPlacement, true))
|
sessionKey := firstNonEmpty(ib.xhttpSessionKey, defaultXHTTPMetaKey(sessionPlacement, true))
|
||||||
seqKey := firstNonEmpty(ib.xhttpSeqKey, defaultXHTTPMetaKey(seqPlacement, false))
|
seqKey := firstNonEmpty(ib.xhttpSeqKey, defaultXHTTPMetaKey(seqPlacement, false))
|
||||||
|
|
||||||
// Matches xray-core ExtractMetaFromRequest: split the path suffix after the
|
|
||||||
// base directly, without trimming empty segments, so segment indices line up
|
|
||||||
// exactly with what the client produced via appendToPath.
|
|
||||||
var parts []string
|
var parts []string
|
||||||
pathPart := 0
|
pathPart := 0
|
||||||
if sessionPlacement == xhttpPlacementPath || seqPlacement == xhttpPlacementPath {
|
if sessionPlacement == xhttpPlacementPath || seqPlacement == xhttpPlacementPath {
|
||||||
@@ -403,6 +395,7 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
|
|||||||
id: id,
|
id: id,
|
||||||
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts),
|
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
connectedCh: make(chan struct{}),
|
||||||
lastSeen: time.Now(),
|
lastSeen: time.Now(),
|
||||||
}
|
}
|
||||||
ib.xhttpSessions[id] = s
|
ib.xhttpSessions[id] = s
|
||||||
@@ -419,35 +412,19 @@ func (ib *nativeInbound) xhttpMaxActiveSessions() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
|
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
|
||||||
// Keep the cheap unconnected cleanup, but also reap stale sessions that never
|
timer := time.NewTimer(30 * time.Second)
|
||||||
// receive their paired download/close because a mobile network or CDN path died.
|
defer timer.Stop()
|
||||||
unconnected := time.NewTimer(20 * time.Second)
|
|
||||||
stale := time.NewTicker(30 * time.Second)
|
|
||||||
defer unconnected.Stop()
|
|
||||||
defer stale.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-unconnected.C:
|
case <-timer.C:
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
connected := s.connected
|
connected := s.connected
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
ib.deleteXHTTPSession(id, s)
|
ib.deleteXHTTPSession(id, s)
|
||||||
s.close()
|
s.close()
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-stale.C:
|
|
||||||
s.mu.Lock()
|
|
||||||
idle := time.Since(s.lastSeen)
|
|
||||||
s.mu.Unlock()
|
|
||||||
if idle > 5*time.Minute {
|
|
||||||
ib.deleteXHTTPSession(id, s)
|
|
||||||
s.close()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
case <-s.connectedCh:
|
||||||
case <-s.done:
|
case <-s.done:
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,11 +621,6 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ
|
|||||||
sess.touch()
|
sess.touch()
|
||||||
defer xrayRecover(fmt.Sprintf("native xray XHTTP download inbound=%q session=%q remote=%s", ib.tag, sessionID, r.RemoteAddr))
|
defer xrayRecover(fmt.Sprintf("native xray XHTTP download inbound=%q session=%q remote=%s", ib.tag, sessionID, r.RemoteAddr))
|
||||||
xrayTracef("native xray: xhttp stream-down inbound=%q session=%q proto=%s remote=%s", ib.tag, sessionID, r.Proto, r.RemoteAddr)
|
xrayTracef("native xray: xhttp stream-down inbound=%q session=%q proto=%s remote=%s", ib.tag, sessionID, r.Proto, r.RemoteAddr)
|
||||||
// Do NOT reject a second download GET for the same session. xray-core does not,
|
|
||||||
// and rejecting one permanently breaks reconnects: when the client re-opens the
|
|
||||||
// downlink after a network hiccup (or H2 retries it) while the previous handler
|
|
||||||
// is still blocked on a now-dead socket, a 400 makes the client tear the whole
|
|
||||||
// session down and every retry keeps failing.
|
|
||||||
sess.markConnected()
|
sess.markConnected()
|
||||||
defer ib.deleteXHTTPSession(sessionID, sess)
|
defer ib.deleteXHTTPSession(sessionID, sess)
|
||||||
|
|
||||||
@@ -716,6 +688,8 @@ type nativeXHTTPSession struct {
|
|||||||
queue *nativeXHTTPUploadQueue
|
queue *nativeXHTTPUploadQueue
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
|
connectedCh chan struct{} // closed once the download GET attaches
|
||||||
|
connectOnce sync.Once
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
lastSeen time.Time
|
lastSeen time.Time
|
||||||
@@ -727,14 +701,12 @@ func (s *nativeXHTTPSession) touch() {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// markConnected records that the download (stream-down) GET has attached, which
|
|
||||||
// stops the unconnected-session reaper from expiring it. It does not reject a
|
|
||||||
// second attach (xray-core does not either): rejecting breaks client reconnects.
|
|
||||||
func (s *nativeXHTTPSession) markConnected() {
|
func (s *nativeXHTTPSession) markConnected() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.connected = true
|
s.connected = true
|
||||||
s.lastSeen = time.Now()
|
s.lastSeen = time.Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
s.connectOnce.Do(func() { close(s.connectedCh) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *nativeXHTTPSession) close() {
|
func (s *nativeXHTTPSession) close() {
|
||||||
@@ -798,11 +770,6 @@ func (c *nativeXHTTPConn) SetReadDeadline(t time.Time) error {
|
|||||||
|
|
||||||
func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil }
|
func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
// nativeXHTTPResponseWriter mirrors xray-core's httpServerConn.Write: every write
|
|
||||||
// to the download stream is flushed immediately. XHTTP download is an SSE-style
|
|
||||||
// stream, and the client only makes progress on bytes that actually reach it, so
|
|
||||||
// batching writes (behind a timer/threshold) just adds latency and stutter. The
|
|
||||||
// mutex serializes writes against close.
|
|
||||||
type nativeXHTTPResponseWriter struct {
|
type nativeXHTTPResponseWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
w http.ResponseWriter
|
w http.ResponseWriter
|
||||||
@@ -838,15 +805,6 @@ type nativeXHTTPPacket struct {
|
|||||||
Seq uint64
|
Seq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeXHTTPUploadQueue is a faithful port of xray-core's splithttp uploadQueue
|
|
||||||
// (transport/internet/splithttp/upload_queue.go): a bounded channel that feeds a
|
|
||||||
// sequence-number reorder heap. The critical property — matching upstream Xray —
|
|
||||||
// is that push() buffers the packet and returns immediately so the HTTP POST is
|
|
||||||
// acked (200) without waiting for the tunnel reader to consume it. The previous
|
|
||||||
// implementation blocked each POST until consumption, which throttled the uplink
|
|
||||||
// to the reassembly rate and periodically deadlocked against the client's
|
|
||||||
// concurrent-POST limit — the classic "download a burst, stall, repeat" that made
|
|
||||||
// video/large downloads unusable.
|
|
||||||
type nativeXHTTPUploadQueue struct {
|
type nativeXHTTPUploadQueue struct {
|
||||||
pushedPackets chan nativeXHTTPPacket
|
pushedPackets chan nativeXHTTPPacket
|
||||||
maxPackets int
|
maxPackets int
|
||||||
@@ -872,12 +830,8 @@ func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// push buffers one uplink packet (or the stream-up reader) and returns as soon as
|
|
||||||
// it is queued. It only blocks when the bounded buffer is full — the same
|
|
||||||
// backpressure upstream Xray applies — or until the request/session is cancelled.
|
|
||||||
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error {
|
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error {
|
||||||
if p.Reader != nil {
|
if p.Reader != nil {
|
||||||
// Only one stream-up reader may exist per session.
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
if q.reader != nil {
|
if q.reader != nil {
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
@@ -919,8 +873,6 @@ func (q *nativeXHTTPUploadQueue) SetReadDeadline(t time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recv blocks for the next buffered packet, honoring the current read deadline
|
|
||||||
// (used only for the VLESS/VMess handshake timeout; the tunnel body has none).
|
|
||||||
func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
|
func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
d := q.readDeadline
|
d := q.readDeadline
|
||||||
@@ -951,10 +903,6 @@ func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read mirrors xray-core uploadQueue.Read: drain in-order payloads from the heap,
|
|
||||||
// otherwise pull from the channel; misordered packets are buffered until their
|
|
||||||
// predecessor arrives, and an over-large reassembly heap tears the session down
|
|
||||||
// so the client retries.
|
|
||||||
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
||||||
if reader := q.loadReader(); reader != nil {
|
if reader := q.loadReader(); reader != nil {
|
||||||
return reader.Read(b)
|
return reader.Read(b)
|
||||||
@@ -984,9 +932,6 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
|||||||
if packet.Seq == q.nextSeq {
|
if packet.Seq == q.nextSeq {
|
||||||
n := copy(b, packet.Payload)
|
n := copy(b, packet.Payload)
|
||||||
if n < len(packet.Payload) {
|
if n < len(packet.Payload) {
|
||||||
// Partial read: push the remainder back with the same sequence so
|
|
||||||
// the next Read continues it before advancing nextSeq. (This mirrors
|
|
||||||
// xray-core; a separate side buffer would forget to advance nextSeq.)
|
|
||||||
packet.Payload = packet.Payload[n:]
|
packet.Payload = packet.Payload[n:]
|
||||||
heap.Push(&q.heap, packet)
|
heap.Push(&q.heap, packet)
|
||||||
} else {
|
} else {
|
||||||
@@ -996,7 +941,6 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if packet.Seq > q.nextSeq {
|
if packet.Seq > q.nextSeq {
|
||||||
// Misordered: wait for the missing predecessor.
|
|
||||||
if len(q.heap) > q.maxPackets {
|
if len(q.heap) > q.maxPackets {
|
||||||
return 0, errors.New("xhttp upload reassembly buffer too large")
|
return 0, errors.New("xhttp upload reassembly buffer too large")
|
||||||
}
|
}
|
||||||
@@ -1010,7 +954,6 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
heap.Push(&q.heap, p)
|
heap.Push(&q.heap, p)
|
||||||
}
|
}
|
||||||
// packet.Seq < nextSeq: stale/duplicate, already popped — drop it.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user