Tunning and memory control

This commit is contained in:
2026-07-15 00:11:56 -03:00
parent ff175174e4
commit ab6f1e1329
16 changed files with 1828 additions and 258 deletions
+307 -33
View File
@@ -14,12 +14,31 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
const nativeXHTTPServerIdleTimeout = 90 * time.Second
const (
nativeXHTTPMaxSessionIDBytes = 256
nativeXHTTPMaxSequenceBytes = 20
nativeXHTTPHardMaxHeaderBytes = 256 * 1024
nativeXHTTPHardMaxPostBytes int64 = 16 * 1024 * 1024
nativeXHTTPMaxBufferedPosts = 512
nativeXHTTPMaxBufferedSessionBytes = 16 * 1024 * 1024
nativeXHTTPMaxBufferedGlobalBytes = 128 * 1024 * 1024
)
var (
nativeXHTTPBufferedBytes atomic.Int64
nativeXHTTPBufferRejected atomic.Int64
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
)
const (
xhttpPlacementPath = "path"
xhttpPlacementQuery = "query"
@@ -157,7 +176,10 @@ func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
func (g *nativeXHTTPListener) serve(ln net.Listener) {
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
h2s := &http2.Server{}
h2s := &http2.Server{
IdleTimeout: nativeXHTTPServerIdleTimeout,
MaxConcurrentStreams: nativeHTTP2MaxConcurrentStreams(),
}
handler := http.Handler(g)
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
@@ -169,6 +191,7 @@ func (g *nativeXHTTPListener) serve(ln net.Listener) {
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 4 * time.Second,
IdleTimeout: nativeXHTTPServerIdleTimeout,
MaxHeaderBytes: g.headerSize,
}
if g.security == "tls" && g.tlsConfig != nil {
@@ -258,6 +281,9 @@ func (ib *nativeInbound) reapStaleXHTTPSessions(idle time.Duration) {
func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
if ib.xhttpMaxHeaderBytes > 0 {
if ib.xhttpMaxHeaderBytes > nativeXHTTPHardMaxHeaderBytes {
return nativeXHTTPHardMaxHeaderBytes
}
return ib.xhttpMaxHeaderBytes
}
// Xray defaults to 8192. Keep a little room for custom headers/cookies used
@@ -269,19 +295,26 @@ func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
// byte stream to the VLESS/VMess handlers as a net.Conn.
func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP request inbound=%q method=%s path=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.RemoteAddr))
releaseRequest, ok := acquireNativeXHTTPRequest()
if !ok {
w.Header().Set("Retry-After", "1")
http.Error(w, "native XHTTP request limit reached", http.StatusTooManyRequests)
return
}
defer releaseRequest()
if !ib.isXHTTP() {
xrayLogf("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr)
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr)
xhttpBadRequest(w)
return
}
if !ib.xhttpHostAllowed(r.Host) {
xrayLogf("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr)
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr)
w.WriteHeader(http.StatusNotFound)
return
}
base, ok := ib.matchXHTTPPath(r.URL.Path)
if !ok {
xrayLogf("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr)
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr)
w.WriteHeader(http.StatusNotFound)
return
}
@@ -293,6 +326,11 @@ func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
sessionID, seqStr := ib.extractXHTTPMeta(r, base)
if len(sessionID) > nativeXHTTPMaxSessionIDBytes || len(seqStr) > nativeXHTTPMaxSequenceBytes {
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=metadata-size remote=%s", ib.tag, r.RemoteAddr)
xhttpBadRequest(w)
return
}
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)
@@ -520,17 +558,24 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
return s
}
if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max {
// XHTTP uses many HTTP requests/sessions by design. Returning HTTP 429
// makes Xray clients tear down active tunnels, which is worse than allowing
// a short soft-limit overflow and relying on stale-session cleanup.
xrayTracef("native xray: xhttp session soft limit exceeded inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
w.Header().Set("Retry-After", "1")
http.Error(w, "native XHTTP session limit reached", http.StatusTooManyRequests)
logNativePreAuthRejection("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
return nil
}
releaseSlot, ok := acquireNativeXHTTPSession()
if !ok {
w.Header().Set("Retry-After", "1")
http.Error(w, "native XHTTP global session limit reached", http.StatusTooManyRequests)
return nil
}
s := &nativeXHTTPSession{
id: id,
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts),
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts, nativeXHTTPMaxBufferedSessionBytes),
done: make(chan struct{}),
connectedCh: make(chan struct{}),
lastSeen: time.Now(),
releaseSlot: releaseSlot,
}
ib.xhttpSessions[id] = s
xrayTracef("native xray: xhttp session created inbound=%q session=%q active=%d", ib.tag, id, len(ib.xhttpSessions))
@@ -539,10 +584,9 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
}
func (ib *nativeInbound) xhttpMaxActiveSessions() int {
if nativeXHTTPMaxSessionLimit() > 0 {
return nativeXHTTPMaxSessionLimit()
}
return defaultNativeXHTTPMaxSessions
// normalizeNativeXrayTuning already installs the safe default. A zero value
// here therefore intentionally means the operator configured -1 (unlimited).
return nativeXHTTPMaxSessionLimit()
}
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
@@ -570,6 +614,19 @@ func (ib *nativeInbound) deleteXHTTPSession(id string, s *nativeXHTTPSession) {
}
}
func (ib *nativeInbound) closeAllXHTTPSessions() {
ib.xhttpMu.Lock()
sessions := make([]*nativeXHTTPSession, 0, len(ib.xhttpSessions))
for id, session := range ib.xhttpSessions {
delete(ib.xhttpSessions, id)
sessions = append(sessions, session)
}
ib.xhttpMu.Unlock()
for _, session := range sessions {
session.close()
}
}
func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession) {
sess.touch()
xrayTracef("native xray: xhttp stream-up inbound=%q session=%q len=%d remote=%s", ib.tag, sess.id, r.ContentLength, r.RemoteAddr)
@@ -577,7 +634,7 @@ func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.
http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest)
return
}
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}); err != nil {
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}, nil); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
@@ -605,16 +662,29 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
http.Error(w, "bad xhttp sequence", http.StatusBadRequest)
return
}
memory, ok := acquireNativeXHTTPMemory(ib.xhttpMaxPostBytes())
if !ok {
w.Header().Set("Retry-After", "1")
http.Error(w, errNativeXHTTPUploadBufferFull.Error(), http.StatusTooManyRequests)
return
}
defer memory.release()
payload, err := ib.readXHTTPPayload(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
memory.shrink(int64(len(payload)))
xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr)
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}); err != nil {
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, memory); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
if errors.Is(err, errNativeXHTTPUploadBufferFull) {
w.Header().Set("Retry-After", "1")
http.Error(w, err.Error(), http.StatusTooManyRequests)
return
}
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -719,6 +789,9 @@ func (ib *nativeInbound) readXHTTPBodyPayload(r *http.Request) ([]byte, error) {
func (ib *nativeInbound) xhttpMaxPostBytes() int64 {
if ib.xhttpMaxEachPostBytes > 0 {
if ib.xhttpMaxEachPostBytes > nativeXHTTPHardMaxPostBytes {
return nativeXHTTPHardMaxPostBytes
}
return ib.xhttpMaxEachPostBytes
}
return 1_000_000
@@ -794,7 +867,7 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ
// The stream-down HTTP request is the lifetime owner of an XHTTP
// session. Log the actual transport cancellation so a CDN/proxy
// timeout can be distinguished from a server idle policy.
xrayLogf("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
xrayTracef("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
ib.tag, sessionID, r.RemoteAddr, r.Context().Err())
_ = xc.Close()
case <-sess.done:
@@ -859,6 +932,7 @@ type nativeXHTTPSession struct {
mu sync.Mutex
connected bool
lastSeen time.Time
releaseSlot func()
}
func (s *nativeXHTTPSession) touch() {
@@ -879,6 +953,9 @@ func (s *nativeXHTTPSession) close() {
s.closeOnce.Do(func() {
close(s.done)
s.queue.close()
if s.releaseSlot != nil {
s.releaseSlot()
}
})
}
@@ -980,6 +1057,70 @@ func (w *nativeXHTTPResponseWriter) close() {
w.mu.Unlock()
}
// nativeXHTTPMemoryLease reserves from a process-wide byte budget before a
// packet-up handler allocates its payload. The same lease is transferred to the
// session queue, so active request bodies and queued reassembly data share one
// hard ceiling instead of each having an independent amplification window.
type nativeXHTTPMemoryLease struct {
bytes int64
}
func acquireNativeXHTTPMemory(n int64) (*nativeXHTTPMemoryLease, bool) {
if n <= 0 {
return &nativeXHTTPMemoryLease{}, true
}
for {
current := nativeXHTTPBufferedBytes.Load()
if current > nativeXHTTPMaxBufferedGlobalBytes-n {
logNativeLimitRejection("XHTTP buffered upload bytes", &nativeXHTTPBufferRejected, nativeXHTTPMaxBufferedGlobalBytes)
return nil, false
}
if nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
return &nativeXHTTPMemoryLease{bytes: n}, true
}
}
}
func releaseNativeXHTTPMemory(n int64) {
if n <= 0 {
return
}
for {
current := nativeXHTTPBufferedBytes.Load()
next := current - n
if next < 0 {
next = 0
}
if nativeXHTTPBufferedBytes.CompareAndSwap(current, next) {
return
}
}
}
func (l *nativeXHTTPMemoryLease) shrink(n int64) {
if l == nil {
return
}
if n < 0 {
n = 0
}
if n >= l.bytes {
return
}
release := l.bytes - n
l.bytes = n
releaseNativeXHTTPMemory(release)
}
func (l *nativeXHTTPMemoryLease) release() {
if l == nil || l.bytes <= 0 {
return
}
n := l.bytes
l.bytes = 0
releaseNativeXHTTPMemory(n)
}
type nativeXHTTPPacket struct {
Reader io.ReadCloser
Payload []byte
@@ -989,44 +1130,127 @@ type nativeXHTTPPacket struct {
type nativeXHTTPUploadQueue struct {
pushedPackets chan nativeXHTTPPacket
maxPackets int
maxBytes int64
mu sync.Mutex
reader io.ReadCloser
heap nativeXHTTPHeap
nextSeq uint64
readDeadline time.Time
// readMu serializes the single decoded stream reader with close-time queue
// cleanup. pushWG lets close wait until every producer that started before
// closedFlag was set has either transferred or released its memory lease.
readMu sync.Mutex
pushWG sync.WaitGroup
mu sync.Mutex
reader io.ReadCloser
readerQueued bool
heap nativeXHTTPHeap
nextSeq uint64
readDeadline time.Time
bufferedBytes int64
closedFlag bool
closed chan struct{}
closeOnce sync.Once
}
func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue {
func newNativeXHTTPUploadQueue(maxPackets int, maxBytes int64) *nativeXHTTPUploadQueue {
if maxPackets <= 0 {
maxPackets = defaultNativeXHTTPBufferedPosts
}
if maxPackets > nativeXHTTPMaxBufferedPosts {
maxPackets = nativeXHTTPMaxBufferedPosts
}
if maxBytes <= 0 || maxBytes > nativeXHTTPMaxBufferedSessionBytes {
maxBytes = nativeXHTTPMaxBufferedSessionBytes
}
return &nativeXHTTPUploadQueue{
pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
maxPackets: maxPackets,
maxBytes: maxBytes,
closed: make(chan struct{}),
}
}
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error {
func (q *nativeXHTTPUploadQueue) beginPush() bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.closedFlag {
return false
}
q.pushWG.Add(1)
return true
}
func (q *nativeXHTTPUploadQueue) adoptPayloadMemory(memory *nativeXHTTPMemoryLease, n int64) bool {
if n <= 0 {
return true
}
if memory == nil || memory.bytes != n {
return false
}
q.mu.Lock()
defer q.mu.Unlock()
if q.closedFlag || q.bufferedBytes > q.maxBytes-n {
return false
}
q.bufferedBytes += n
memory.bytes = 0
return true
}
func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) {
if n <= 0 {
return
}
q.mu.Lock()
release := n
if release > q.bufferedBytes {
release = q.bufferedBytes
}
q.bufferedBytes -= release
q.mu.Unlock()
releaseNativeXHTTPMemory(release)
}
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket, memory *nativeXHTTPMemoryLease) error {
if !q.beginPush() {
return io.ErrClosedPipe
}
defer q.pushWG.Done()
readerReserved := false
if p.Reader != nil {
q.mu.Lock()
if q.reader != nil {
if q.reader != nil || q.readerQueued || q.closedFlag {
q.mu.Unlock()
return errors.New("xhttp upload reader already exists")
}
q.readerQueued = true
readerReserved = true
q.mu.Unlock()
defer func() {
if readerReserved {
q.mu.Lock()
q.readerQueued = false
q.mu.Unlock()
}
}()
}
payloadBytes := int64(len(p.Payload))
if !q.adoptPayloadMemory(memory, payloadBytes) {
return errNativeXHTTPUploadBufferFull
}
transferred := payloadBytes > 0
if transferred {
defer func() {
if payloadBytes > 0 {
q.releasePayloadMemory(payloadBytes)
}
}()
}
select {
case q.pushedPackets <- p:
select {
case <-q.closed:
return io.ErrClosedPipe
default:
}
// Ownership has moved to the queue. close() waits for this producer and
// then drains/releases anything not consumed by the stream reader.
payloadBytes = 0
readerReserved = false
return nil
case <-q.closed:
return io.ErrClosedPipe
@@ -1037,13 +1261,41 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket)
func (q *nativeXHTTPUploadQueue) close() {
q.closeOnce.Do(func() {
close(q.closed)
q.mu.Lock()
q.closedFlag = true
reader := q.reader
close(q.closed)
q.mu.Unlock()
if reader != nil {
_ = reader.Close()
}
q.pushWG.Wait()
q.readMu.Lock()
// No producers or readers can now change the queue. Drop references to
// buffered payloads promptly and return their exact byte reservation.
for {
select {
case p := <-q.pushedPackets:
if p.Reader != nil {
_ = p.Reader.Close()
}
p.Payload = nil
default:
goto drained
}
}
drained:
q.mu.Lock()
remaining := q.bufferedBytes
q.bufferedBytes = 0
for i := range q.heap {
q.heap[i].Payload = nil
}
q.heap = nil
q.mu.Unlock()
q.readMu.Unlock()
releaseNativeXHTTPMemory(remaining)
})
}
@@ -1085,6 +1337,9 @@ func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
}
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
q.readMu.Lock()
defer q.readMu.Unlock()
if reader := q.loadReader(); reader != nil {
return reader.Read(b)
}
@@ -1101,9 +1356,18 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
return 0, err
}
if p.Reader != nil {
q.setReader(p.Reader)
if !q.setReader(p.Reader) {
_ = p.Reader.Close()
return 0, io.EOF
}
return p.Reader.Read(b)
}
select {
case <-q.closed:
q.releasePayloadMemory(int64(len(p.Payload)))
return 0, io.EOF
default:
}
heap.Push(&q.heap, p)
}
@@ -1112,6 +1376,7 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
if packet.Seq == q.nextSeq {
n := copy(b, packet.Payload)
q.releasePayloadMemory(int64(n))
if n < len(packet.Payload) {
packet.Payload = packet.Payload[n:]
heap.Push(&q.heap, packet)
@@ -1131,10 +1396,15 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
return 0, err
}
if p.Reader != nil {
_ = p.Reader.Close()
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
}
heap.Push(&q.heap, p)
continue
}
// A duplicate/late packet is discarded; release the bytes it owned.
q.releasePayloadMemory(int64(len(packet.Payload)))
}
return 0, nil
@@ -1146,10 +1416,14 @@ func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
return q.reader
}
func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) {
func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.closedFlag {
return false
}
q.reader = r
q.mu.Unlock()
return true
}
type nativeXHTTPHeap []nativeXHTTPPacket