mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
fix(outbound): measure HTTP test delay on a warm connection
Since the batched prober replaced the single tester, the reported delay came from one cold request with keep-alives disabled, so it stacked the SOCKS handshake, proxy dial, proxy TLS, target TCP and target TLS on top of the round-trip. Users upgrading from v2.9.4 - whose tester warmed the connection first and timed a second request - saw several times the real connection time. The cold request still proves reachability and supplies the HTTP status plus the connect/TLS/TTFB breakdown; the delay is now re-measured on a second request over the kept-alive connection, falling back to the cold total when the warm request fails. Bodies are drained (bounded) so the connection returns to the pool, and the batch test asserts both requests of a probe share one connection.
This commit is contained in:
@@ -17,8 +17,9 @@ import {
|
|||||||
const DIRTY_POLL_MS = 1000;
|
const DIRTY_POLL_MS = 1000;
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
// One HTTP-mode batch request tests this many outbounds through a single
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
// shared temp xray instance; chunking keeps responses bounded (~15s worst
|
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||||
// case) and lands Test All results progressively.
|
// case — each probe is a cold plus a warm request) and lands Test All
|
||||||
|
// results progressively.
|
||||||
const HTTP_BATCH_CHUNK = 16;
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
|
|||||||
@@ -111,8 +111,9 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|||||||
|
|
||||||
// TestOutboundResult represents the result of testing an outbound.
|
// TestOutboundResult represents the result of testing an outbound.
|
||||||
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
||||||
// probes; HTTP mode reports the time of a real HTTP request routed
|
// probes; HTTP mode reports the round-trip of a real HTTP request on an
|
||||||
// through the outbound, with an optional timing breakdown.
|
// established connection through the outbound (the cold first request
|
||||||
|
// supplies the timing breakdown).
|
||||||
type TestOutboundResult struct {
|
type TestOutboundResult struct {
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -28,11 +29,18 @@ import (
|
|||||||
// client-side (instead of polling xray's observatory) returns the moment the
|
// client-side (instead of polling xray's observatory) returns the moment the
|
||||||
// response lands, yields the actual HTTP status, and allows an httptrace
|
// response lands, yields the actual HTTP status, and allows an httptrace
|
||||||
// timing breakdown — while the shared process keeps "Test All" at one xray
|
// timing breakdown — while the shared process keeps "Test All" at one xray
|
||||||
// spawn per batch instead of one per outbound.
|
// spawn per batch instead of one per outbound. The reported delay comes from
|
||||||
|
// a second request on the kept-alive connection, so it reflects the tunnel's
|
||||||
|
// real per-request round-trip rather than the stacked SOCKS/proxy/TLS
|
||||||
|
// handshakes of connection establishment.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// httpProbeTimeout bounds one probe request end-to-end.
|
// httpProbeTimeout bounds each probe request end-to-end (a probe makes
|
||||||
|
// two: a cold one for the breakdown, a warm one for the delay).
|
||||||
httpProbeTimeout = 10 * time.Second
|
httpProbeTimeout = 10 * time.Second
|
||||||
|
// probeDrainLimit caps how much response body a probe reads back to keep
|
||||||
|
// the connection reusable for the warm request.
|
||||||
|
probeDrainLimit = 256 << 10
|
||||||
// httpProbeConcurrency caps parallel probe requests within a batch —
|
// httpProbeConcurrency caps parallel probe requests within a batch —
|
||||||
// enough to keep a batch fast, low enough not to spike CPU with TLS
|
// enough to keep a batch fast, low enough not to spike CPU with TLS
|
||||||
// handshakes on small VPSes.
|
// handshakes on small VPSes.
|
||||||
@@ -427,18 +435,22 @@ func outboundsContainTag(outbounds []any, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeThroughSocks issues one timed GET through the local SOCKS inbound at
|
// probeThroughSocks probes the local SOCKS inbound at the given port and
|
||||||
// the given port and fills result. Any HTTP response — including 4xx/5xx and
|
// fills result. A first, cold GET proves reachability and carries the
|
||||||
// unfollowed redirects — counts as reachable; only transport-level failures
|
// httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed
|
||||||
// (refused, reset, timeout, proxy errors) are failures. Delay is request
|
// redirects — counts as reachable; only transport-level failures (refused,
|
||||||
// start → response headers; the test URL's hostname is resolved by xray
|
// reset, timeout, proxy errors) are failures. Delay is then re-measured on a
|
||||||
// (Go's SOCKS5 client sends the domain to the proxy), so DNS goes through
|
// warm request over the kept-alive connection — the real round-trip through
|
||||||
// the outbound too.
|
// the established tunnel — falling back to the cold total if the warm request
|
||||||
|
// fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
|
||||||
|
// sends the domain to the proxy), so DNS goes through the outbound too.
|
||||||
func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
|
func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
|
||||||
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
|
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
DisableKeepAlives: true,
|
MaxIdleConns: 1,
|
||||||
|
MaxIdleConnsPerHost: 1,
|
||||||
|
IdleConnTimeout: timeout,
|
||||||
}
|
}
|
||||||
defer tr.CloseIdleConnections()
|
defer tr.CloseIdleConnections()
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@@ -496,15 +508,14 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
delay := time.Since(start).Milliseconds()
|
coldDelay := time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Error = err.Error()
|
result.Error = err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
drainAndClose(resp)
|
||||||
|
|
||||||
result.Success = true
|
result.Success = true
|
||||||
result.Delay = max(delay, 1)
|
|
||||||
result.HTTPStatus = resp.StatusCode
|
result.HTTPStatus = resp.StatusCode
|
||||||
if connDone {
|
if connDone {
|
||||||
result.ConnectMs = max(connDur.Milliseconds(), 1)
|
result.ConnectMs = max(connDur.Milliseconds(), 1)
|
||||||
@@ -515,6 +526,36 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
|
|||||||
if gotFirstRB {
|
if gotFirstRB {
|
||||||
result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
|
result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delay := coldDelay
|
||||||
|
if warmDelay, ok := timedWarmGet(client, testURL); ok {
|
||||||
|
delay = warmDelay
|
||||||
|
}
|
||||||
|
result.Delay = max(delay, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timedWarmGet re-issues the probe request over the transport's kept-alive
|
||||||
|
// connection and returns its duration — the tunnel's per-request round-trip.
|
||||||
|
func timedWarmGet(client *http.Client, testURL string) (int64, bool) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
delay := time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
drainAndClose(resp)
|
||||||
|
return delay, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAndClose consumes the body (bounded by probeDrainLimit) so the
|
||||||
|
// connection returns to the keep-alive pool for the warm request.
|
||||||
|
func drainAndClose(resp *http.Response) {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeDrainLimit))
|
||||||
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
|
// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -399,7 +400,12 @@ func TestTestOutboundsTCPLane(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
requestsPerConn := make(map[string]int)
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
requestsPerConn[r.RemoteAddr]++
|
||||||
|
mu.Unlock()
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -443,6 +449,19 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
|||||||
if proc.IsRunning() {
|
if proc.IsRunning() {
|
||||||
t.Error("temp process not stopped after batch")
|
t.Error("temp process not stopped after batch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
totalRequests := 0
|
||||||
|
for addr, n := range requestsPerConn {
|
||||||
|
totalRequests += n
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("connection %s served %d requests, want 2 (warm delay request must reuse the cold request's connection)", addr, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalRequests != 4 {
|
||||||
|
t.Errorf("test URL served %d requests, want 4 (cold + warm per probe)", totalRequests)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user