diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 271513011..1c9121b87 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -17,8 +17,9 @@ import { const DIRTY_POLL_MS = 1000; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; // One HTTP-mode batch request tests this many outbounds through a single -// shared temp xray instance; chunking keeps responses bounded (~15s worst -// case) and lands Test All results progressively. +// shared temp xray instance; chunking keeps responses bounded (~30s worst +// case — each probe is a cold plus a warm request) and lands Test All +// results progressively. const HTTP_BATCH_CHUNK = 16; export function isUdpOutbound(outbound: unknown): boolean { diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index cffc27ccf..acdee8fa9 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -111,8 +111,9 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error { // TestOutboundResult represents the result of testing an outbound. // Delay is in milliseconds. Endpoints is only populated for TCP-mode -// probes; HTTP mode reports the time of a real HTTP request routed -// through the outbound, with an optional timing breakdown. +// probes; HTTP mode reports the round-trip of a real HTTP request on an +// established connection through the outbound (the cold first request +// supplies the timing breakdown). type TestOutboundResult struct { Tag string `json:"tag,omitempty"` Success bool `json:"success"` diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go index a32c34e94..b43451ca9 100644 --- a/internal/web/service/outbound/probe_http.go +++ b/internal/web/service/outbound/probe_http.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net" "net/http" @@ -28,11 +29,18 @@ import ( // client-side (instead of polling xray's observatory) returns the moment the // response lands, yields the actual HTTP status, and allows an httptrace // 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 ( - // 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 + // 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 — // enough to keep a batch fast, low enough not to spike CPU with TLS // handshakes on small VPSes. @@ -427,18 +435,22 @@ func outboundsContainTag(outbounds []any, tag string) bool { return false } -// probeThroughSocks issues one timed GET through the local SOCKS inbound at -// the given port and fills result. Any HTTP response — including 4xx/5xx and -// unfollowed redirects — counts as reachable; only transport-level failures -// (refused, reset, timeout, proxy errors) are failures. Delay is request -// start → response headers; 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. +// probeThroughSocks probes the local SOCKS inbound at the given port and +// fills result. A first, cold GET proves reachability and carries the +// httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed +// redirects — counts as reachable; only transport-level failures (refused, +// reset, timeout, proxy errors) are failures. Delay is then re-measured on a +// warm request over the kept-alive connection — the real round-trip through +// 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) { proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))} tr := &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - DisableKeepAlives: true, + Proxy: http.ProxyURL(proxyURL), + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: timeout, } defer tr.CloseIdleConnections() client := &http.Client{ @@ -496,15 +508,14 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result * return } resp, err := client.Do(req) - delay := time.Since(start).Milliseconds() + coldDelay := time.Since(start).Milliseconds() if err != nil { result.Error = err.Error() return } - resp.Body.Close() + drainAndClose(resp) result.Success = true - result.Delay = max(delay, 1) result.HTTPStatus = resp.StatusCode if connDone { result.ConnectMs = max(connDur.Milliseconds(), 1) @@ -515,6 +526,36 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result * if gotFirstRB { 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 diff --git a/internal/web/service/outbound/probe_http_test.go b/internal/web/service/outbound/probe_http_test.go index ab58c3118..9b81ccb2f 100644 --- a/internal/web/service/outbound/probe_http_test.go +++ b/internal/web/service/outbound/probe_http_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "strconv" "strings" + "sync" "testing" "time" @@ -399,7 +400,12 @@ func TestTestOutboundsTCPLane(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) { + mu.Lock() + requestsPerConn[r.RemoteAddr]++ + mu.Unlock() w.WriteHeader(http.StatusNoContent) })) defer srv.Close() @@ -443,6 +449,19 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) { if proc.IsRunning() { 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) {