diff --git a/adapter/experimental.go b/adapter/experimental.go index b3d8c3b804..2e8a7c2ee2 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -21,8 +21,16 @@ type ClashServer interface { } type URLTestHistory struct { - Time time.Time `json:"time"` - Delay uint16 `json:"delay"` + Time time.Time `json:"time"` + // Delay is the time to response headers, in milliseconds. + Delay uint16 `json:"delay"` + // Throughput is the smoothed effective transfer rate in bytes per second, or + // zero when bandwidth testing is disabled or no sample has been taken yet. + // It is a ranking signal measured over a few hundred KiB, not a speed test + // result, and understates the capacity of a fast path. + Throughput uint32 `json:"throughput,omitempty"` + // Bytes is the number of body bytes read by the most recent bandwidth probe. + Bytes uint32 `json:"bytes,omitempty"` } type V2RayServer interface { diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 36d4495790..22111b04fd 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -3,6 +3,8 @@ package urltest import ( "context" "crypto/tls" + "io" + "math" "net" "net/http" "net/url" @@ -12,6 +14,8 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" @@ -65,6 +69,47 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes s.access.Unlock() } +// StoreURLTestDelay records a latency sample, preserving any bandwidth sample already +// stored for the tag. Prefer it over StoreURLTestHistory when recording a latency +// probe, so that a manually triggered delay test does not discard throughput data. +func (s *HistoryStorage) StoreURLTestDelay(tag string, delay uint16) { + s.access.Lock() + history := &adapter.URLTestHistory{ + Time: time.Now(), + Delay: delay, + } + if previous := s.delayHistory[tag]; previous != nil { + history.Throughput = previous.Throughput + history.Bytes = previous.Bytes + } + s.delayHistory[tag] = history + s.notifyUpdated() + s.access.Unlock() +} + +// StoreURLTestBandwidth records a bandwidth sample, preserving the latency sample. It +// does nothing when no latency sample exists, since an outbound without one is not +// selectable and an entry with a zero delay would corrupt latency ranking. +// +// Entries are replaced rather than mutated in place, because readers hold the pointer +// returned by LoadURLTestHistory outside the lock. +func (s *HistoryStorage) StoreURLTestBandwidth(tag string, throughput uint32, bytes uint32) { + s.access.Lock() + previous := s.delayHistory[tag] + if previous == nil { + s.access.Unlock() + return + } + s.delayHistory[tag] = &adapter.URLTestHistory{ + Time: previous.Time, + Delay: previous.Delay, + Throughput: throughput, + Bytes: bytes, + } + s.notifyUpdated() + s.access.Unlock() +} + func (s *HistoryStorage) notifyUpdated() { for _, updateHook := range s.updateHooks { updateHook.Emit(struct{}{}) @@ -145,3 +190,162 @@ func urlTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err e t = uint16(time.Since(start) / time.Millisecond) return } + +// bandwidthReadBufferSize bounds the memory a bandwidth probe retains. The body is +// read into this one reused buffer and discarded, so retained memory is independent +// of the byte cap — which matters on the iOS network extension, where the process +// runs under a hard jetsam limit. +const bandwidthReadBufferSize = 32 * 1024 + +// BandwidthResult is the outcome of a bounded bandwidth probe. +type BandwidthResult struct { + // Delay is the time to response headers in milliseconds, measured with the same + // semantics as URLTest. + Delay uint16 + // Bytes is the number of body bytes read before the cap or the deadline. + Bytes uint32 + // Duration is the time spent reading those bytes, excluding Delay, so that the + // rate reflects the data phase rather than connection setup. + Duration time.Duration +} + +// minTransferDuration floors the measured transfer time. A clock with coarse +// resolution — Windows in particular — reports zero for a transfer that completes +// within one tick, and treating that as zero throughput would rank an immeasurably +// fast path as the worst one and drop it out of contention entirely. Flooring instead +// reports bytes/1ms, which understates such a path but keeps its ordering right. +const minTransferDuration = time.Millisecond + +// Throughput returns the effective transfer rate in bytes per second. +func (r *BandwidthResult) Throughput() uint32 { + if r.Bytes == 0 { + return 0 + } + duration := max(r.Duration, minTransferDuration) + throughput := float64(r.Bytes) / duration.Seconds() + if throughput >= math.MaxUint32 { + return math.MaxUint32 + } + return uint32(throughput) +} + +// BandwidthTest measures both latency and effective throughput over detour by issuing +// a GET against link and reading at most maxBytes of the response body, cancelling as +// soon as the cap is reached rather than draining the remainder. +// +// It is deliberately a ranking signal rather than a benchmark: a cap in the low +// hundreds of KiB is reached while the flow is still in or near slow start, so the +// absolute rate understates a fast path's true capacity. The ratio between a shaped +// and an unshaped path is already large at that scale, which is what selection needs. +func BandwidthTest(ctx context.Context, link string, detour N.Dialer, maxBytes uint32, timeout time.Duration) (*BandwidthResult, error) { + multiplexOutbound, isMultiplexOutbound := common.Cast[adapter.OutboundWithMultiplex](detour) + if isMultiplexOutbound && multiplexOutbound.MultiplexEnabled() { + // Warm up the multiplex session so its establishment cost is excluded, as in + // URLTest. A HEAD carries no body, so this does not consume the payload. + _, err := urlTest(ctx, link, detour) + if err != nil { + return nil, err + } + } + return bandwidthTest(ctx, link, detour, maxBytes, timeout) +} + +func bandwidthTest(ctx context.Context, link string, detour N.Dialer, maxBytes uint32, timeout time.Duration) (*BandwidthResult, error) { + linkURL, err := url.Parse(link) + if err != nil { + return nil, err + } + hostname := linkURL.Hostname() + port := linkURL.Port() + if port == "" { + switch linkURL.Scheme { + case "http": + port = "80" + case "https": + port = "443" + } + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + start := time.Now() + instance, err := detour.DialContext(ctx, "tcp", M.ParseSocksaddrHostPortStr(hostname, port)) + if err != nil { + return nil, err + } + defer instance.Close() + if N.NeedHandshakeForWrite(instance) { + start = time.Now() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) + if err != nil { + return nil, err + } + client := http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return instance, nil + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + // Measure the bytes on the wire, not what the origin chose to compress. + DisableCompression: true, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: timeout, + } + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + // An error page is small and arrives fast, which would score as an excellent + // path. Rate limiting in particular must not be mistaken for throughput. + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, E.New("unexpected status: ", resp.Status) + } + result := &BandwidthResult{ + Delay: uint16(time.Since(start) / time.Millisecond), + } + + buffer := buf.Get(bandwidthReadBufferSize) + defer buf.Put(buffer) + transferStart := time.Now() + var readBytes uint32 + for readBytes < maxBytes { + chunk := buffer + if remaining := maxBytes - readBytes; uint32(len(chunk)) > remaining { + chunk = chunk[:remaining] + } + n, readErr := resp.Body.Read(chunk) + readBytes += uint32(n) + if readErr != nil { + if readErr != io.EOF { + err = readErr + } + break + } + } + result.Duration = time.Since(transferStart) + result.Bytes = readBytes + // Cancel before closing so the remainder of the body is torn down rather than + // drained for connection reuse; draining it would defeat the cap. + cancel() + resp.Body.Close() + + if readBytes == 0 { + if err != nil { + return nil, err + } + return nil, E.New("empty response body") + } + // A probe that timed out short of the cap still transferred real bytes, and a + // timeout is itself evidence of a slow path, so the sample is kept. + return result, nil +} diff --git a/common/urltest/urltest_test.go b/common/urltest/urltest_test.go new file mode 100644 index 0000000000..a7311049f3 --- /dev/null +++ b/common/urltest/urltest_test.go @@ -0,0 +1,130 @@ +package urltest + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + M "github.com/sagernet/sing/common/metadata" + + "github.com/stretchr/testify/require" +) + +type directDialer struct{} + +func (directDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, destination.String()) +} + +func (directDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func TestBandwidthResultThroughput(t *testing.T) { + t.Parallel() + require.Equal(t, uint32(1024), (&BandwidthResult{Bytes: 1024, Duration: time.Second}).Throughput()) + require.Equal(t, uint32(2048), (&BandwidthResult{Bytes: 1024, Duration: 500 * time.Millisecond}).Throughput()) + // A transfer faster than the clock can resolve reports the floored rate, not zero: + // zero would exclude the fastest path from throughput ranking altogether. + require.Equal(t, uint32(1024000), (&BandwidthResult{Bytes: 1024}).Throughput()) + require.Equal(t, uint32(1024000), (&BandwidthResult{Bytes: 1024, Duration: time.Microsecond}).Throughput()) + // No bytes means no sample, whatever the clock says. + require.Zero(t, (&BandwidthResult{Duration: time.Second}).Throughput()) +} + +// TestBandwidthTestCap is the property the whole design rests on: the probe reads the +// cap and stops, however much the server is willing to send. +func TestBandwidthTestCap(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + chunk := make([]byte, 32*1024) + // Far more than the cap, and more than the test would tolerate draining. + for range 512 { + if _, err := w.Write(chunk); err != nil { + return + } + } + })) + defer server.Close() + + const maxBytes = 64 * 1024 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := BandwidthTest(ctx, server.URL, directDialer{}, maxBytes, 10*time.Second) + require.NoError(t, err) + require.Equal(t, uint32(maxBytes), result.Bytes) + // Duration itself may legitimately be zero on a coarse clock, so assert on the + // derived rate, which is what selection actually consumes. + require.Positive(t, result.Throughput()) +} + +func TestBandwidthTestShortBody(t *testing.T) { + t.Parallel() + const bodySize = 1000 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(make([]byte, bodySize)) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := BandwidthTest(ctx, server.URL, directDialer{}, 64*1024, 10*time.Second) + require.NoError(t, err) + require.Equal(t, uint32(bodySize), result.Bytes) +} + +// TestBandwidthTestRejectsErrorStatus guards the failure mode where a rate-limited +// endpoint returns a small error page fast and scores as an excellent path. +func TestBandwidthTestRejectsErrorStatus(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("slow down")) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := BandwidthTest(ctx, server.URL, directDialer{}, 64*1024, 10*time.Second) + require.Error(t, err) +} + +func TestBandwidthTestEmptyBody(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := BandwidthTest(ctx, server.URL, directDialer{}, 64*1024, 10*time.Second) + require.Error(t, err) +} + +func TestStoreURLTestPreservesOtherMetric(t *testing.T) { + t.Parallel() + storage := NewHistoryStorage() + defer storage.Close() + + // A bandwidth sample cannot create an entry on its own: without a latency sample + // the outbound is not selectable, and a zero delay would corrupt latency ranking. + storage.StoreURLTestBandwidth("proxy", 4096, 65536) + require.Nil(t, storage.LoadURLTestHistory("proxy")) + + storage.StoreURLTestDelay("proxy", 100) + storage.StoreURLTestBandwidth("proxy", 4096, 65536) + history := storage.LoadURLTestHistory("proxy") + require.NotNil(t, history) + require.Equal(t, uint16(100), history.Delay) + require.Equal(t, uint32(4096), history.Throughput) + + // A later latency probe must not discard the throughput sample. + storage.StoreURLTestDelay("proxy", 120) + history = storage.LoadURLTestHistory("proxy") + require.Equal(t, uint16(120), history.Delay) + require.Equal(t, uint32(4096), history.Throughput) + require.Equal(t, uint32(65536), history.Bytes) +} diff --git a/constant/timeout.go b/constant/timeout.go index dd2e94d837..8daadcad6a 100644 --- a/constant/timeout.go +++ b/constant/timeout.go @@ -3,21 +3,22 @@ package constant import "time" const ( - TCPKeepAliveInitial = 5 * time.Minute - TCPKeepAliveInterval = 75 * time.Second - TCPConnectTimeout = 5 * time.Second - TCPTimeout = 15 * time.Second - ReadPayloadTimeout = 300 * time.Millisecond - DNSTimeout = 10 * time.Second - UDPTimeout = 5 * time.Minute - ICMPTimeout = 10 * time.Second - DefaultURLTestInterval = 3 * time.Minute - DefaultURLTestIdleTimeout = 30 * time.Minute - StartTimeout = 10 * time.Second - StopTimeout = 5 * time.Second - FatalStopTimeout = 10 * time.Second - FakeIPMetadataSaveInterval = 10 * time.Second - TLSFragmentFallbackDelay = 500 * time.Millisecond + TCPKeepAliveInitial = 5 * time.Minute + TCPKeepAliveInterval = 75 * time.Second + TCPConnectTimeout = 5 * time.Second + TCPTimeout = 15 * time.Second + ReadPayloadTimeout = 300 * time.Millisecond + DNSTimeout = 10 * time.Second + UDPTimeout = 5 * time.Minute + ICMPTimeout = 10 * time.Second + DefaultURLTestInterval = 3 * time.Minute + DefaultURLTestIdleTimeout = 30 * time.Minute + DefaultURLTestBandwidthTimeout = 5 * time.Second + StartTimeout = 10 * time.Second + StopTimeout = 5 * time.Second + FatalStopTimeout = 10 * time.Second + FakeIPMetadataSaveInterval = 10 * time.Second + TLSFragmentFallbackDelay = 500 * time.Millisecond ) var PortProtocols = map[uint16]string{ diff --git a/constant/urltest.go b/constant/urltest.go new file mode 100644 index 0000000000..84dce1ec13 --- /dev/null +++ b/constant/urltest.go @@ -0,0 +1,20 @@ +package constant + +// DefaultURLTestBandwidthURLPrefix is the counterpart to the latency probe's +// generate_204 endpoint. The requested byte count is appended from max_bytes, so the +// response is exactly as large as the probe will read and nothing is sent that the +// probe will cancel away. +const DefaultURLTestBandwidthURLPrefix = "https://speed.cloudflare.com/__down?bytes=" + +// Selection strategies for the urltest outbound group. +const ( + // URLTestStrategyLatency ranks outbounds by the latency probe alone. This is the + // default, and the only strategy available when bandwidth testing is disabled. + URLTestStrategyLatency = "latency" + // URLTestStrategyThroughput ranks outbounds by measured throughput, ignoring + // latency beyond liveness. + URLTestStrategyThroughput = "throughput" + // URLTestStrategyThroughputWithLatencyFloor discards outbounds whose latency + // exceeds the configured floor, then ranks the survivors by throughput. + URLTestStrategyThroughputWithLatencyFloor = "throughput_with_latency_floor" +) diff --git a/daemon/started_service.go b/daemon/started_service.go index 7e2557aac6..5f58e71090 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -712,10 +712,7 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( if err != nil { historyStorage.DeleteURLTestHistory(itemTag) } else { - historyStorage.StoreURLTestHistory(itemTag, &adapter.URLTestHistory{ - Time: time.Now(), - Delay: t, - }) + historyStorage.StoreURLTestDelay(itemTag, t) } return nil, nil }) @@ -726,10 +723,7 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( if err != nil { historyStorage.DeleteURLTestHistory(outboundTag) } else { - historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ - Time: time.Now(), - Delay: t, - }) + historyStorage.StoreURLTestDelay(outboundTag, t) } }() } diff --git a/docs/configuration/outbound/urltest.md b/docs/configuration/outbound/urltest.md index f4b3b0aa8e..878f6df8e5 100644 --- a/docs/configuration/outbound/urltest.md +++ b/docs/configuration/outbound/urltest.md @@ -14,7 +14,19 @@ "interval": "", "tolerance": 0, "idle_timeout": "", - "interrupt_exist_connections": false + "interrupt_exist_connections": false, + "bandwidth_test": { + "enabled": false, + "url": "", + "max_bytes": 0, + "timeout": "", + "interval": "", + "concurrency": 0, + "strategy": "", + "latency_floor": "", + "throughput_tolerance": 0, + "samples": 0 + } } ``` @@ -47,3 +59,121 @@ The idle timeout. `30m` will be used if empty. Interrupt existing connections when the selected outbound has changed. Only inbound connections are affected by this setting, internal connections will always be interrupted. + +#### bandwidth_test + +!!! question "Since sing-box 1.14.0" + +Optional bandwidth-aware probing, disabled by default. + +The latency probe measures the time to response headers, which says nothing about sustained +throughput. On a congested or shaped path the two decouple: a small probe can finish quickly on a +path whose throughput has collapsed, because it completes before the connection leaves slow start. +When this is enabled, each outbound is additionally probed with a bounded `GET`, and the effective +transfer rate becomes available as a selection input. + +When disabled, the probe path is unchanged and no response body is ever transferred. + +!!! warning "This is not a speed test" + + The measurement is deliberately bounded, and reads only a few hundred KiB — while the flow is + still in or near slow start. The absolute rate understates a fast path's real capacity, and must + not be presented to users as a speed test result. It is a *ranking* signal: the ratio between a + shaped and an unshaped path is already large at this scale. Shaping that only engages after + several MiB will not be detected. + +!!! warning "Data usage" + + Unlike the latency probe, this transfers a payload over every outbound on every interval. At the + default 256 KiB with 10 outbounds every 15 minutes, that is roughly 10 MiB/hour, which matters + on a metered connection. Probing inherits the group's idle suspension and pause handling, so it + stops while the group is unused or the device is asleep. + +#### bandwidth_test.enabled + +Enable bandwidth probing. + +#### bandwidth_test.url + +The URL to download from. It must return a body of at least `max_bytes`; `generate_204` returns no +body and cannot be used. + +`https://speed.cloudflare.com/__down?bytes=` will be used if empty — the byte count +tracks `max_bytes`, so the response is exactly as large as the probe reads. + +!!! warning "Prefer your own endpoint" + + The default is shared by every client that enables this feature. If you operate a suitable + endpoint, or your provider does, point at that instead. + +The URL must return `2xx` directly. Redirects are not followed, so a redirecting endpoint fails the +probe — use the final URL. Note that the latency probe accepts any status while this one requires +`2xx`, so an endpoint can pass latency probing and still report zero throughput. + +#### bandwidth_test.max_bytes + +A hard cap on the body bytes read per probe. `262144` (256 KiB) will be used if empty; values above +`1048576` (1 MiB) are rejected. + +This caps bytes *read*, not bytes *retained* — the body is read into one small reusable buffer and +discarded, so memory use is independent of this setting. + +#### bandwidth_test.timeout + +Per-probe timeout. `5s` will be used if empty. + +A probe that times out short of the cap still yields a sample, computed from the bytes actually +transferred: a timeout is itself evidence of a slow path. + +#### bandwidth_test.interval + +The bandwidth test interval. Five times `interval` will be used if empty. + +The right cadence for a throughput probe is much lower than for a liveness probe. Setting this +higher than `idle_timeout` means probing will rarely run. + +#### bandwidth_test.concurrency + +How many bandwidth probes may run at once. `2` will be used if empty. + +Deliberately far below the latency sweep's fixed 10: these probes consume bandwidth, so running many +at once makes them contend with each other and skews every result. + +#### bandwidth_test.strategy + +How the selection is ranked: + +| Strategy | Behavior | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `latency` | Default. Rank by latency, unchanged. Throughput is measured and exposed, but not used for selection. | +| `throughput` | Rank by throughput. Latency is ignored beyond liveness. | +| `throughput_with_latency_floor` | Discard outbounds whose latency exceeds `latency_floor`, then rank the survivors by throughput. | + +Selection falls back to latency ranking whenever no outbound has a throughput sample yet — during +startup, before the first bandwidth sweep, and after a network change. + +#### bandwidth_test.latency_floor + +Under `throughput_with_latency_floor`, outbounds whose latency exceeds this are excluded from +throughput ranking. Empty or `0` disables the floor, which makes that strategy equivalent to +`throughput`. + +This keeps an outbound that is pathologically slow to connect from winning on bulk transfer alone, +which matters for interactive traffic. + +#### bandwidth_test.throughput_tolerance + +Relative hysteresis, as a percentage. `25` will be used if empty. + +A challenger only replaces the current outbound when its throughput exceeds it by this margin. The +band is relative rather than absolute because throughput ratios are the meaningful comparison. This +is the throughput counterpart of `tolerance`. + +#### bandwidth_test.samples + +How many recent samples to smooth over, using the median. `3` will be used if empty. + +Throughput samples are noisier than latency samples — a probe landing during a transient burst can +swing the value severalfold. Smoothing keeps the group from oscillating and repeatedly interrupting +connections. A failed probe is recorded as a zero sample, so a sustained failure decays an outbound +out of contention while a single transient one is absorbed. diff --git a/docs/configuration/outbound/urltest.zh.md b/docs/configuration/outbound/urltest.zh.md index 4372298afc..ba12c4dc50 100644 --- a/docs/configuration/outbound/urltest.zh.md +++ b/docs/configuration/outbound/urltest.zh.md @@ -14,7 +14,19 @@ "interval": "", "tolerance": 50, "idle_timeout": "", - "interrupt_exist_connections": false + "interrupt_exist_connections": false, + "bandwidth_test": { + "enabled": false, + "url": "", + "max_bytes": 0, + "timeout": "", + "interval": "", + "concurrency": 0, + "strategy": "", + "latency_floor": "", + "throughput_tolerance": 0, + "samples": 0 + } } ``` @@ -46,4 +58,104 @@ 当选定的出站发生更改时,中断现有连接。 -仅入站连接受此设置影响,内部连接将始终被中断。 \ No newline at end of file +仅入站连接受此设置影响,内部连接将始终被中断。 + +#### bandwidth_test + +!!! question "自 sing-box 1.14.0 起" + +可选的带宽感知探测,默认禁用。 + +延迟探测测量的是获取响应头所用的时间,它并不能反映持续吞吐量。在拥塞或被限速的链路上,两者会明显背离: +探测在连接离开慢启动之前就已完成,因此即使链路的持续吞吐量已经崩溃,探测仍然可以很快返回。 +启用后,将额外使用有限长度的 `GET` 请求探测每个出站,其有效传输速率可作为选择依据。 + +禁用时,探测路径保持不变,且不会传输任何响应正文。 + +!!! warning "这不是测速" + + 该测量被刻意限制在几百 KiB 以内,此时连接仍处于或接近慢启动阶段。其绝对数值会低估高速链路的真实容量, + 不应作为测速结果呈现给用户。它是一个**排序**信号:在这个量级上,被限速与未被限速链路之间的比值已经足够大。 + 仅在传输数 MiB 之后才生效的限速无法被检测到。 + +!!! warning "流量消耗" + + 与延迟探测不同,此探测会在每个间隔内通过每个出站传输数据。以默认的 256 KiB、10 个出站、每 15 分钟一次计算, + 约为每小时 10 MiB,这在计量连接上不容忽视。探测沿用了组的空闲挂起与暂停机制,因此在组未被使用或设备休眠时不会运行。 + +#### bandwidth_test.enabled + +启用带宽探测。 + +#### bandwidth_test.url + +用于下载的链接。它必须返回至少 `max_bytes` 大小的正文;`generate_204` 不返回正文,因此不可使用。 + +默认使用 `https://speed.cloudflare.com/__down?bytes=` — 请求的字节数跟随 `max_bytes`, +因此响应大小恰好等于探测将要读取的字节数。 + +!!! warning "建议使用您自己的端点" + + 默认端点将被所有启用此功能的客户端共享。如果您或您的服务提供商拥有合适的端点,请改用它。 + +该链接必须直接返回 `2xx`。重定向不会被跟随,因此会导致探测失败 — 请使用最终的链接。 +请注意,延迟探测接受任何状态码,而此探测要求 `2xx`,因此某个端点可能通过延迟探测但吞吐量始终为零。 + +#### bandwidth_test.max_bytes + +每次探测读取正文字节数的硬性上限。默认使用 `262144`(256 KiB);超过 `1048576`(1 MiB)的值将被拒绝。 + +此项限制的是**读取**的字节数,而非**保留**的字节数 — 正文被读入一个可复用的小缓冲区后即被丢弃, +因此内存占用与此设置无关。 + +#### bandwidth_test.timeout + +单次探测超时。默认使用 `5s`。 + +未达到上限即超时的探测仍会产生一个样本,按实际传输的字节数计算:超时本身就是链路缓慢的证据。 + +#### bandwidth_test.interval + +带宽测试间隔。默认使用 `interval` 的五倍。 + +吞吐量探测的合适频率远低于存活性探测。若将其设置为大于 `idle_timeout`,探测将很少运行。 + +#### bandwidth_test.concurrency + +同时进行的带宽探测数量。默认使用 `2`。 + +该值刻意远低于延迟测试固定的 10:这些探测会实际消耗带宽,同时运行过多会使它们相互争抢,从而扭曲所有结果。 + +#### bandwidth_test.strategy + +选择的排序方式: + +| 策略 | 行为 | +| ------------------------------- | ------------------------------------------------------------------------ | +| `latency` | 默认值。按延迟排序,与原有行为一致。吞吐量会被测量并公开,但不参与选择。 | +| `throughput` | 按吞吐量排序。除存活性外忽略延迟。 | +| `throughput_with_latency_floor` | 排除延迟超过 `latency_floor` 的出站,然后按吞吐量对其余出站排序。 | + +当尚无出站拥有吞吐量样本时,选择将回退到延迟排序 — 包括启动时、首次带宽测试之前,以及网络变更之后。 + +#### bandwidth_test.latency_floor + +在 `throughput_with_latency_floor` 策略下,延迟超过此值的出站将被排除在吞吐量排序之外。 +留空或 `0` 将禁用该下限,此时该策略等同于 `throughput`。 + +这可以防止连接速度极慢的出站仅凭批量传输能力胜出,这对交互式流量很重要。 + +#### bandwidth_test.throughput_tolerance + +以百分比表示的相对容差。默认使用 `25`。 + +只有当挑战者的吞吐量超出当前出站该比例时,才会将其替换。之所以使用相对而非绝对容差, +是因为吞吐量的比值才是有意义的比较。此项是 `tolerance` 在吞吐量维度上的对应项。 + +#### bandwidth_test.samples + +用于平滑的最近样本数量,取中位数。默认使用 `3`。 + +吞吐量样本比延迟样本噪声大得多 — 恰好落在流量突发期的一次探测可能使数值波动数倍。 +平滑可避免组反复振荡并中断连接。探测失败会被记录为零样本,因此持续失败会使出站逐渐退出竞争, +而单次瞬时失败则会被吸收。 \ No newline at end of file diff --git a/docs/schema.json b/docs/schema.json index a831eafc8a..490bce1125 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -13493,6 +13493,9 @@ }, "interrupt_exist_connections": { "type": "boolean" + }, + "bandwidth_test": { + "$ref": "#/$defs/URLTestBandwidthTestOptions" } }, "required": [ @@ -17080,6 +17083,46 @@ } ] }, + "URLTestBandwidthTestOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "max_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "interval": { + "$ref": "#/$defs/Duration" + }, + "concurrency": { + "type": "integer" + }, + "strategy": { + "type": "string" + }, + "latency_floor": { + "$ref": "#/$defs/Duration" + }, + "throughput_tolerance": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "samples": { + "type": "integer" + } + }, + "additionalProperties": false + }, "USBIPDeviceMatch": { "type": "object", "properties": { diff --git a/experimental/clashapi/api_meta_group.go b/experimental/clashapi/api_meta_group.go index 31dbdaf692..237d46f5cb 100644 --- a/experimental/clashapi/api_meta_group.go +++ b/experimental/clashapi/api_meta_group.go @@ -111,10 +111,7 @@ func getGroupDelay(server *Server) func(w http.ResponseWriter, r *http.Request) server.urlTestHistory.DeleteURLTestHistory(realTag) } else { server.logger.Debug("outbound ", tag, " available: ", t, "ms") - server.urlTestHistory.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ - Time: time.Now(), - Delay: t, - }) + server.urlTestHistory.StoreURLTestDelay(realTag, t) resultAccess.Lock() result[tag] = t resultAccess.Unlock() diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go index ef88ff37c5..78c60cf62f 100644 --- a/experimental/clashapi/proxies.go +++ b/experimental/clashapi/proxies.go @@ -208,10 +208,7 @@ func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) if err != nil { server.urlTestHistory.DeleteURLTestHistory(realTag) } else { - server.urlTestHistory.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ - Time: time.Now(), - Delay: delay, - }) + server.urlTestHistory.StoreURLTestDelay(realTag, delay) } }() diff --git a/option/group.go b/option/group.go index bc0c07e479..301b3195ea 100644 --- a/option/group.go +++ b/option/group.go @@ -9,10 +9,32 @@ type SelectorOutboundOptions struct { } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds" reference:"outbound"` - URL string `json:"url,omitempty"` - Interval badoption.Duration `json:"interval,omitempty"` - Tolerance uint16 `json:"tolerance,omitempty"` - IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + Outbounds []string `json:"outbounds" reference:"outbound"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Tolerance uint16 `json:"tolerance,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + BandwidthTest *URLTestBandwidthTestOptions `json:"bandwidth_test,omitempty"` +} + +// URLTestBandwidthTestOptions configures an optional bandwidth probe that supplements +// the latency probe. It is disabled by default; when disabled, the probe path is +// unchanged and no response body is ever transferred. +type URLTestBandwidthTestOptions struct { + Enabled bool `json:"enabled,omitempty"` + // URL must return a body of at least MaxBytes. When empty, a Cloudflare speed + // endpoint sized to MaxBytes is used; prefer your own, since a shared default is + // fetched by every client that enables this. + URL string `json:"url,omitempty"` + // MaxBytes is a hard cap on the number of body bytes read per probe. It bounds + // the bytes read, not the memory retained; the reader uses a small fixed buffer. + MaxBytes uint32 `json:"max_bytes,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Concurrency int `json:"concurrency,omitempty"` + Strategy string `json:"strategy,omitempty"` + LatencyFloor badoption.Duration `json:"latency_floor,omitempty"` + ThroughputTolerance uint16 `json:"throughput_tolerance,omitempty"` + Samples int `json:"samples,omitempty"` } diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index d97235b784..415ff387d9 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -2,7 +2,10 @@ package group import ( "context" + "math" "net" + "slices" + "strconv" "sync" "sync/atomic" "time" @@ -46,6 +49,7 @@ type URLTest struct { idleTimeout time.Duration group *URLTestGroup interruptExternalConnections bool + bandwidthTest *option.URLTestBandwidthTestOptions } func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { @@ -61,6 +65,7 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo tolerance: options.Tolerance, idleTimeout: time.Duration(options.IdleTimeout), interruptExternalConnections: options.InterruptExistConnections, + bandwidthTest: options.BandwidthTest, } if len(outbound.tags) == 0 { return nil, E.New("missing tags") @@ -77,7 +82,7 @@ func (s *URLTest) Start() error { } outbounds = append(outbounds, detour) } - group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) + group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections, s.bandwidthTest) if err != nil { return err } @@ -125,6 +130,11 @@ func (s *URLTest) InterfaceUpdated() { if group.pause.IsDevicePaused() || group.pause.IsNetworkPaused() { return } + // Throughput observed on the previous network says nothing about this one, and + // re-probing every outbound on every interface change would be expensive on + // exactly the devices where interface changes are frequent. Drop the samples and + // let selection fall back to latency until the next scheduled bandwidth sweep. + group.resetBandwidth() go group.CheckOutbounds(true) } @@ -204,9 +214,126 @@ type URLTestGroup struct { close chan struct{} started bool lastActive common.TypedValue[time.Time] + + // bandwidth is nil unless bandwidth testing is enabled, which keeps every code + // path below a no-op by default. + bandwidth *bandwidthTestOptions + bandwidthChecking atomic.Bool + bandwidthTicker *time.Ticker + bandwidthPauseCallback *list.Element[pause.Callback] + bandwidthAccess sync.Mutex + bandwidthHistory map[string]*bandwidthState + updateAccess sync.Mutex +} + +// bandwidthState holds the smoothing window for one outbound. Throughput samples are +// far noisier than latency samples — a probe landing during a transient burst can +// swing severalfold — so selection reads the smoothed value, never the last sample. +type bandwidthState struct { + samples []uint32 + smoothed uint32 + lastTest time.Time +} + +type bandwidthTestOptions struct { + link string + maxBytes uint32 + timeout time.Duration + interval time.Duration + concurrency int + strategy string + latencyFloor uint16 // milliseconds; zero disables the floor + throughputTolerance uint16 // percent + samples int +} + +const ( + defaultBandwidthMaxBytes = 256 * 1024 + // maxBandwidthMaxBytes caps what a single probe may pull. This is a ranking + // signal, and the cost is paid on every outbound on every interval, potentially + // over metered data. + maxBandwidthMaxBytes = 1024 * 1024 + // defaultBandwidthConcurrency is deliberately far below the latency sweep's + // fixed 10: these probes consume bandwidth, so running many at once makes them + // contend with each other and skews every result. + defaultBandwidthConcurrency = 2 + // defaultBandwidthIntervalMultiplier derives the bandwidth interval from the + // latency interval, since the right cadence for a throughput probe is much lower + // than for a liveness probe. + defaultBandwidthIntervalMultiplier = 5 + defaultBandwidthTolerance = 25 + defaultBandwidthSamples = 3 +) + +func newBandwidthTestOptions(options *option.URLTestBandwidthTestOptions, interval time.Duration) (*bandwidthTestOptions, error) { + if options == nil || !options.Enabled { + return nil, nil + } + parsed := &bandwidthTestOptions{ + link: options.URL, + maxBytes: options.MaxBytes, + timeout: time.Duration(options.Timeout), + interval: time.Duration(options.Interval), + concurrency: options.Concurrency, + strategy: options.Strategy, + throughputTolerance: options.ThroughputTolerance, + samples: options.Samples, + } + if parsed.maxBytes == 0 { + parsed.maxBytes = defaultBandwidthMaxBytes + } else if parsed.maxBytes > maxBandwidthMaxBytes { + return nil, E.New("bandwidth_test.max_bytes must be less or equal than ", maxBandwidthMaxBytes) + } + if parsed.link == "" { + // Derived after max_bytes is resolved so the endpoint returns exactly the + // number of bytes the probe intends to read. + parsed.link = C.DefaultURLTestBandwidthURLPrefix + strconv.FormatUint(uint64(parsed.maxBytes), 10) + } + if parsed.timeout == 0 { + parsed.timeout = C.DefaultURLTestBandwidthTimeout + } else if parsed.timeout < 0 { + return nil, E.New("bandwidth_test.timeout must be positive") + } + if parsed.interval == 0 { + parsed.interval = interval * defaultBandwidthIntervalMultiplier + } else if parsed.interval < 0 { + return nil, E.New("bandwidth_test.interval must be positive") + } + if parsed.concurrency == 0 { + parsed.concurrency = defaultBandwidthConcurrency + } else if parsed.concurrency < 0 { + return nil, E.New("bandwidth_test.concurrency must be positive") + } + switch parsed.strategy { + case "": + parsed.strategy = C.URLTestStrategyLatency + case C.URLTestStrategyLatency, C.URLTestStrategyThroughput, C.URLTestStrategyThroughputWithLatencyFloor: + default: + return nil, E.New("unknown bandwidth_test.strategy: ", parsed.strategy) + } + latencyFloor := time.Duration(options.LatencyFloor) + if latencyFloor < 0 { + return nil, E.New("bandwidth_test.latency_floor must be positive") + } + if floorMilliseconds := latencyFloor.Milliseconds(); floorMilliseconds > math.MaxUint16 { + // Delay is measured in uint16 milliseconds, so a larger floor can never + // exclude anything anyway. + parsed.latencyFloor = math.MaxUint16 + } else { + parsed.latencyFloor = uint16(floorMilliseconds) + } + if parsed.throughputTolerance == 0 { + parsed.throughputTolerance = defaultBandwidthTolerance + } + if parsed.samples == 0 { + parsed.samples = defaultBandwidthSamples + } else if parsed.samples < 0 { + return nil, E.New("bandwidth_test.samples must be positive") + } + return parsed, nil } -func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) { +func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool, bandwidthOptions *option.URLTestBandwidthTestOptions) (*URLTestGroup, error) { if interval == 0 { interval = C.DefaultURLTestInterval } @@ -219,6 +346,13 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage if interval > idleTimeout { return nil, E.New("interval must be less or equal than idle_timeout") } + bandwidth, err := newBandwidthTestOptions(bandwidthOptions, interval) + if err != nil { + return nil, err + } + if bandwidth != nil && bandwidth.interval > idleTimeout { + logger.Warn("urltest: bandwidth_test.interval (", bandwidth.interval, ") is greater than idle_timeout (", idleTimeout, "); bandwidth probing will rarely run") + } history := service.PtrFromContext[urltest.HistoryStorage](ctx) if history == nil { return nil, E.New("missing URL test history storage") @@ -237,6 +371,8 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage pause: service.FromContext[pause.Manager](ctx), interruptGroup: interrupt.NewGroup(), interruptExternalConnections: interruptExternalConnections, + bandwidth: bandwidth, + bandwidthHistory: make(map[string]*bandwidthState), }, nil } @@ -256,29 +392,71 @@ func (g *URLTestGroup) Touch() { defer g.access.Unlock() if g.ticker != nil { g.lastActive.Store(time.Now()) + // The two tickers suspend independently, and the latency one suspends first + // because it ticks more often, so the bandwidth ticker may still need + // restarting even when the latency one is already running. + g.startBandwidthTicker() return } ticker := time.NewTicker(g.interval) g.ticker = ticker g.pauseCallback = pause.RegisterTicker(g.pause, ticker, g.interval, nil) go g.loopCheck(ticker, g.close) + g.startBandwidthTicker() +} + +// startBandwidthTicker must be called with access held. +func (g *URLTestGroup) startBandwidthTicker() { + if g.bandwidth == nil || g.bandwidthTicker != nil { + return + } + // A separate ticker rather than a divisor of the latency one, so the two cadences + // stay independent. It inherits the same pause registration and the same idle + // suspension, so no probing happens while the group is unused or the device is + // asleep. + ticker := time.NewTicker(g.bandwidth.interval) + g.bandwidthTicker = ticker + g.bandwidthPauseCallback = pause.RegisterTicker(g.pause, ticker, g.bandwidth.interval, nil) + go g.loopBandwidthCheck(ticker, g.close) } func (g *URLTestGroup) Close() error { g.access.Lock() defer g.access.Unlock() - if g.ticker == nil { + // Checked independently: either ticker may already have suspended itself on idle + // timeout while the other is still running. + if g.ticker == nil && g.bandwidthTicker == nil { return nil } - g.ticker.Stop() - g.ticker = nil - g.pause.UnregisterCallback(g.pauseCallback) - g.pauseCallback = nil + if g.ticker != nil { + g.ticker.Stop() + g.ticker = nil + g.pause.UnregisterCallback(g.pauseCallback) + g.pauseCallback = nil + } + if g.bandwidthTicker != nil { + g.bandwidthTicker.Stop() + g.bandwidthTicker = nil + g.pause.UnregisterCallback(g.bandwidthPauseCallback) + g.bandwidthPauseCallback = nil + } close(g.close) return nil } func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { + if g.bandwidth != nil && g.bandwidth.strategy != C.URLTestStrategyLatency { + // Falls through to latency ranking until throughput samples exist, which + // covers startup, the interval before the first bandwidth sweep, and the + // case where every outbound was excluded by the latency floor. + if outbound, exists, ranked := g.selectByThroughput(network); ranked { + return outbound, exists + } + } + return g.selectByLatency(network) +} + +func (g *URLTestGroup) selectByLatency(network string) (adapter.Outbound, bool) { var minDelay uint16 var minOutbound adapter.Outbound switch network { @@ -322,6 +500,206 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { return minOutbound, true } +// selectByThroughput ranks eligible outbounds by smoothed throughput. The third +// return value reports whether any outbound was eligible at all; when it is false the +// caller falls back to latency ranking rather than leaving the group unselected. +func (g *URLTestGroup) selectByThroughput(network string) (adapter.Outbound, bool, bool) { + var maxThroughput uint32 + var maxOutbound adapter.Outbound + // Seed with the incumbent so it keeps the same advantage it has under latency + // ranking: a challenger has to beat it by the tolerance, not merely tie it. + var selected adapter.Outbound + switch network { + case N.NetworkTCP: + selected = g.selectedOutboundTCP + case N.NetworkUDP: + selected = g.selectedOutboundUDP + } + if selected != nil { + if throughput, eligible := g.candidateThroughput(selected); eligible { + maxOutbound = selected + maxThroughput = throughput + } + } + for _, detour := range g.outbounds { + if !common.Contains(detour.Network(), network) { + continue + } + throughput, eligible := g.candidateThroughput(detour) + if !eligible { + continue + } + if maxOutbound == nil || beatsIncumbent(throughput, maxThroughput, g.bandwidth.throughputTolerance) { + maxThroughput = throughput + maxOutbound = detour + } + } + if maxOutbound == nil { + return nil, false, false + } + return maxOutbound, true, true +} + +// candidateThroughput reports the smoothed throughput of detour and whether it may be +// ranked by it: the outbound needs a latency sample (liveness), a non-zero throughput +// sample, and — under throughput_with_latency_floor — a latency within the floor. +func (g *URLTestGroup) candidateThroughput(detour adapter.Outbound) (uint32, bool) { + realTag := RealTag(detour) + history := g.history.LoadURLTestHistory(realTag) + if history == nil { + return 0, false + } + if g.bandwidth.strategy == C.URLTestStrategyThroughputWithLatencyFloor && + g.bandwidth.latencyFloor > 0 && + history.Delay > g.bandwidth.latencyFloor { + return 0, false + } + throughput := g.loadBandwidth(realTag) + if throughput == 0 { + return 0, false + } + return throughput, true +} + +func (g *URLTestGroup) loadBandwidth(tag string) uint32 { + g.bandwidthAccess.Lock() + defer g.bandwidthAccess.Unlock() + if state := g.bandwidthHistory[tag]; state != nil { + return state.smoothed + } + return 0 +} + +// recordBandwidth appends a sample to the smoothing window and returns the new +// smoothed value. A failed probe is recorded as a zero sample rather than dropped, so +// that a genuinely broken path decays out of contention while a single transient +// failure is absorbed by the median. +func (g *URLTestGroup) recordBandwidth(tag string, throughput uint32) uint32 { + g.bandwidthAccess.Lock() + defer g.bandwidthAccess.Unlock() + state := g.bandwidthHistory[tag] + if state == nil { + state = new(bandwidthState) + g.bandwidthHistory[tag] = state + } + state.samples = append(state.samples, throughput) + if len(state.samples) > g.bandwidth.samples { + state.samples = state.samples[len(state.samples)-g.bandwidth.samples:] + } + state.lastTest = time.Now() + state.smoothed = medianThroughput(state.samples) + return state.smoothed +} + +func (g *URLTestGroup) bandwidthExpired(tag string) bool { + g.bandwidthAccess.Lock() + defer g.bandwidthAccess.Unlock() + state := g.bandwidthHistory[tag] + return state == nil || time.Since(state.lastTest) >= g.bandwidth.interval +} + +// resetBandwidth discards every sample, so selection falls back to latency until the +// next bandwidth sweep re-measures the new path. +func (g *URLTestGroup) resetBandwidth() { + if g.bandwidth == nil { + return + } + g.bandwidthAccess.Lock() + clear(g.bandwidthHistory) + g.bandwidthAccess.Unlock() +} + +// beatsIncumbent reports whether a challenger's throughput clears the incumbent's by +// the configured margin. The hysteresis is relative rather than absolute because +// throughput ratios are the meaningful comparison: a fixed byte-rate band would mean +// something entirely different on a slow link than on a fast one. +func beatsIncumbent(challenger uint32, incumbent uint32, tolerancePercent uint16) bool { + return uint64(challenger) > uint64(incumbent)*uint64(100+tolerancePercent)/100 +} + +func medianThroughput(samples []uint32) uint32 { + if len(samples) == 0 { + return 0 + } + sorted := slices.Clone(samples) + slices.Sort(sorted) + middle := len(sorted) / 2 + if len(sorted)%2 == 1 { + return sorted[middle] + } + return uint32((uint64(sorted[middle-1]) + uint64(sorted[middle])) / 2) +} + +func (g *URLTestGroup) loopBandwidthCheck(ticker *time.Ticker, closeChan <-chan struct{}) { + g.bandwidthTest(g.ctx, false) + for { + select { + case <-closeChan: + return + case <-ticker.C: + } + if time.Since(g.lastActive.Load()) > g.idleTimeout { + g.access.Lock() + if g.bandwidthTicker == ticker { + g.bandwidthTicker.Stop() + g.bandwidthTicker = nil + g.pause.UnregisterCallback(g.bandwidthPauseCallback) + g.bandwidthPauseCallback = nil + } + g.access.Unlock() + return + } + g.bandwidthTest(g.ctx, false) + } +} + +func (g *URLTestGroup) bandwidthTest(ctx context.Context, force bool) { + if g.bandwidth == nil { + return + } + if g.bandwidthChecking.Swap(true) { + return + } + defer g.bandwidthChecking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](g.bandwidth.concurrency)) + checked := make(map[string]bool) + for _, detour := range g.outbounds { + tag := detour.Tag() + realTag := RealTag(detour) + if checked[realTag] { + continue + } + if !force && !g.bandwidthExpired(realTag) { + continue + } + checked[realTag] = true + p, loaded := g.outbound.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + testCtx, cancel := context.WithTimeout(g.ctx, g.bandwidth.timeout) + defer cancel() + var throughput, readBytes uint32 + result, err := urltest.BandwidthTest(testCtx, g.bandwidth.link, p, g.bandwidth.maxBytes, g.bandwidth.timeout) + if err != nil { + // Only the throughput sample is affected; the latency history is left + // alone, so a failed bandwidth probe cannot deselect an outbound that + // the latency probe still considers reachable. + g.logger.Debug("outbound ", tag, " bandwidth test failed: ", err) + } else { + throughput = result.Throughput() + readBytes = result.Bytes + g.logger.Debug("outbound ", tag, " bandwidth: ", throughput, " B/s over ", readBytes, " bytes") + } + g.history.StoreURLTestBandwidth(realTag, g.recordBandwidth(realTag, throughput), readBytes) + return nil, nil + }) + } + b.Wait() + g.performUpdateCheck() +} + func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{}) { if time.Since(g.lastActive.Load()) > g.interval { g.lastActive.Store(time.Now()) @@ -389,10 +767,7 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint g.history.DeleteURLTestHistory(realTag) } else { g.logger.Debug("outbound ", tag, " available: ", t, "ms") - g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ - Time: time.Now(), - Delay: t, - }) + g.history.StoreURLTestDelay(realTag, t) resultAccess.Lock() result[tag] = t resultAccess.Unlock() @@ -406,6 +781,11 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint } func (g *URLTestGroup) performUpdateCheck() { + // The latency sweep and the bandwidth sweep have independent re-entrancy guards + // and can finish at the same time, so this needs its own lock against two + // concurrent writers to the selected outbounds. + g.updateAccess.Lock() + defer g.updateAccess.Unlock() var updated bool if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) { if g.selectedOutboundTCP != nil { diff --git a/protocol/group/urltest_test.go b/protocol/group/urltest_test.go new file mode 100644 index 0000000000..532ef17fda --- /dev/null +++ b/protocol/group/urltest_test.go @@ -0,0 +1,155 @@ +package group + +import ( + "testing" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/stretchr/testify/require" +) + +func TestNewBandwidthTestOptionsDisabled(t *testing.T) { + t.Parallel() + parsed, err := newBandwidthTestOptions(nil, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Nil(t, parsed) + + parsed, err = newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + URL: "https://example.com/payload", + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Nil(t, parsed, "an unset enabled flag must leave the feature inert") +} + +func TestNewBandwidthTestOptionsDefaults(t *testing.T) { + t.Parallel() + parsed, err := newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: "https://example.com/payload", + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, uint32(defaultBandwidthMaxBytes), parsed.maxBytes) + require.Equal(t, C.DefaultURLTestBandwidthTimeout, parsed.timeout) + require.Equal(t, defaultBandwidthConcurrency, parsed.concurrency) + require.Equal(t, uint16(defaultBandwidthTolerance), parsed.throughputTolerance) + require.Equal(t, defaultBandwidthSamples, parsed.samples) + // Latency ranking stays the default even once measurement is on. + require.Equal(t, C.URLTestStrategyLatency, parsed.strategy) + require.Zero(t, parsed.latencyFloor) + // The throughput cadence derives from the latency cadence rather than matching it. + require.Equal(t, C.DefaultURLTestInterval*defaultBandwidthIntervalMultiplier, parsed.interval) +} + +func TestNewBandwidthTestOptionsDefaultURL(t *testing.T) { + t.Parallel() + parsed, err := newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Equal(t, C.DefaultURLTestBandwidthURLPrefix+"262144", parsed.link) + + // The default endpoint is sized to the cap, so raising max_bytes must raise the + // request too rather than leaving the probe short of its own limit. + parsed, err = newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + MaxBytes: 512 * 1024, + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Equal(t, C.DefaultURLTestBandwidthURLPrefix+"524288", parsed.link) + + // An explicit URL is never overridden. + parsed, err = newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: "https://example.com/payload", + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Equal(t, "https://example.com/payload", parsed.link) +} + +func TestNewBandwidthTestOptionsValidation(t *testing.T) { + t.Parallel() + for name, options := range map[string]*option.URLTestBandwidthTestOptions{ + "max bytes above cap": {MaxBytes: maxBandwidthMaxBytes + 1}, + "unknown strategy": {Strategy: "fastest"}, + "negative timeout": {Timeout: badoption.Duration(-time.Second)}, + "negative interval": {Interval: badoption.Duration(-time.Second)}, + "negative concurrency": {Concurrency: -1}, + "negative samples": {Samples: -1}, + } { + t.Run(name, func(t *testing.T) { + options.Enabled = true + options.URL = "https://example.com/payload" + _, err := newBandwidthTestOptions(options, C.DefaultURLTestInterval) + require.Error(t, err) + }) + } +} + +func TestNewBandwidthTestOptionsLatencyFloor(t *testing.T) { + t.Parallel() + parsed, err := newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: "https://example.com/payload", + Strategy: C.URLTestStrategyThroughputWithLatencyFloor, + LatencyFloor: badoption.Duration(400 * time.Millisecond), + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Equal(t, uint16(400), parsed.latencyFloor) + + // Delay is stored as uint16 milliseconds, so a larger floor is clamped rather + // than wrapping around into an absurdly small one. + parsed, err = newBandwidthTestOptions(&option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: "https://example.com/payload", + Strategy: C.URLTestStrategyThroughputWithLatencyFloor, + LatencyFloor: badoption.Duration(2 * time.Minute), + }, C.DefaultURLTestInterval) + require.NoError(t, err) + require.Equal(t, uint16(65535), parsed.latencyFloor) +} + +func TestMedianThroughput(t *testing.T) { + t.Parallel() + require.Zero(t, medianThroughput(nil)) + require.Equal(t, uint32(100), medianThroughput([]uint32{100})) + require.Equal(t, uint32(150), medianThroughput([]uint32{100, 200})) + require.Equal(t, uint32(200), medianThroughput([]uint32{100, 200, 300})) + // The point of smoothing: one transient failure among good samples must not + // evict an otherwise healthy outbound. + require.Equal(t, uint32(900), medianThroughput([]uint32{1000, 0, 900})) + // Sustained failure does decay it out of contention. + require.Zero(t, medianThroughput([]uint32{1000, 0, 0})) +} + +func TestBeatsIncumbent(t *testing.T) { + t.Parallel() + // A challenger inside the tolerance band leaves the incumbent in place, so the + // group does not flap and repeatedly interrupt live connections. + require.False(t, beatsIncumbent(1200, 1000, 25)) + require.False(t, beatsIncumbent(1250, 1000, 25)) + require.True(t, beatsIncumbent(1251, 1000, 25)) + require.True(t, beatsIncumbent(1001, 1000, 0)) + require.False(t, beatsIncumbent(1000, 1000, 0)) +} + +func TestRecordBandwidthWindow(t *testing.T) { + t.Parallel() + group := &URLTestGroup{ + bandwidth: &bandwidthTestOptions{samples: 3}, + bandwidthHistory: make(map[string]*bandwidthState), + } + require.Equal(t, uint32(100), group.recordBandwidth("proxy", 100)) + require.Equal(t, uint32(150), group.recordBandwidth("proxy", 200)) + require.Equal(t, uint32(200), group.recordBandwidth("proxy", 300)) + // The window slides rather than growing, so old samples stop counting. + require.Equal(t, uint32(300), group.recordBandwidth("proxy", 400)) + require.Len(t, group.bandwidthHistory["proxy"].samples, 3) + require.Equal(t, uint32(300), group.loadBandwidth("proxy")) + + group.resetBandwidth() + require.Zero(t, group.loadBandwidth("proxy")) +} diff --git a/test/go.mod b/test/go.mod index ecbffe1f6c..325161b0b7 100644 --- a/test/go.mod +++ b/test/go.mod @@ -17,7 +17,7 @@ require ( github.com/sagernet/sing-quic v0.7.0-beta.2 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 - github.com/sagernet/sing-tun v0.8.12-0.20260810013717-909ab10ad507 + github.com/sagernet/sing-tun v0.8.12-0.20260810140529-d67734281390 github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 @@ -153,7 +153,7 @@ require ( github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260807161529-8d42107dcdfc // indirect github.com/sagernet/fswatch v0.1.2 // indirect github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 // indirect - github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect + github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect diff --git a/test/go.sum b/test/go.sum index 57a78f604b..9be009c0e4 100644 --- a/test/go.sum +++ b/test/go.sum @@ -327,8 +327,8 @@ github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PF github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648/go.mod h1:FmW0l0t/PzGIdJMr3iXOL+KuxvJTt6XAfF4GxbMlfZc= -github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPrSEgNII+HkfqC8hKMpYLodom4o4EU= -github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= +github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 h1:IdQ7yTKkB2wv8txwshxUroPlO4npOYAV71xb7xQ7Lys= +github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1/go.mod h1:9O3SQskYuCfdHNvHEsWuEAgoyKEF74PiWp4NsNUia8g= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= @@ -355,8 +355,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260727093646-7cb813e07b73 h1:Iyhoka9XutVbIhkhjn6s2Ez2AEuBG5i4KbkQKqRMsug= github.com/sagernet/sing-snell v0.0.0-20260727093646-7cb813e07b73/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.8.12-0.20260810013717-909ab10ad507 h1:YF57rLEryUvX0OJWpVTebZ9IMpvzRcozhJPcn58lQ9c= -github.com/sagernet/sing-tun v0.8.12-0.20260810013717-909ab10ad507/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.8.12-0.20260810140529-d67734281390 h1:hEBG77TGYDLDtrcfexL+OuwMGNGw18nyhdEAmag2Ia8= +github.com/sagernet/sing-tun v0.8.12-0.20260810140529-d67734281390/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= diff --git a/test/urltest_test.go b/test/urltest_test.go new file mode 100644 index 0000000000..f645dc8965 --- /dev/null +++ b/test/urltest_test.go @@ -0,0 +1,436 @@ +package main + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Each test gets its own ports. Reusing a fixed set across sequentially started +// instances is flaky: a closing instance can still answer on a port the next test has +// just bound, and its probes then fail against a backend that is already gone. +var urlTestPortCursor atomic.Uint32 + +type urlTestEnv struct { + clientPort uint16 + serverPortA uint16 + serverPortB uint16 + clashPort uint16 +} + +func newURLTestEnv() urlTestEnv { + base := uint16(19000 + urlTestPortCursor.Add(10)) + return urlTestEnv{ + clientPort: base, + serverPortA: base + 1, + serverPortB: base + 2, + clashPort: base + 3, + } +} + +const ( + // The probe URLs point at a placeholder destination; each proxy server rewrites + // it to its own backend with a route action, which is what gives the two paths + // independently controllable characteristics. + urlTestLatencyURL = "http://127.0.0.1:1/latency" + urlTestBandwidthURL = "http://127.0.0.1:1/payload" + + urlTestProbeInterval = 500 * time.Millisecond + urlTestMaxProbeBytes = 64 * 1024 + urlTestPayloadChunk = 8 * 1024 + urlTestPayloadChunks = 64 // 512 KiB available, well above the probe cap + urlTestEventually = 15 * time.Second + urlTestEventuallyTick = 100 * time.Millisecond + + // Both delays are non-zero on purpose. Select treats a delay of 0 as "no + // incumbent yet" (protocol/group/urltest.go, minDelay == 0), and a loopback probe + // really can measure 0 ms, which lets the slower outbound override the faster + // one. Keep every backend above a millisecond. + urlTestFastDelay = 50 * time.Millisecond + urlTestSlowDelay = 350 * time.Millisecond +) + +// payloadServer stands in for the endpoint a urltest group probes. Its time to +// response headers and its transfer rate are controlled separately, because the whole +// premise of bandwidth-aware selection is that those two properties decouple: a shaped +// path answers quickly and then crawls. +type payloadServer struct { + server *httptest.Server + headerDelay atomic.Int64 + chunkDelay atomic.Int64 +} + +func startPayloadServer(t *testing.T, headerDelay time.Duration, chunkDelay time.Duration) *payloadServer { + s := new(payloadServer) + s.headerDelay.Store(int64(headerDelay)) + s.chunkDelay.Store(int64(chunkDelay)) + s.server = httptest.NewServer(http.HandlerFunc(s.handle)) + t.Cleanup(s.server.Close) + return s +} + +func (s *payloadServer) handle(w http.ResponseWriter, r *http.Request) { + if delay := time.Duration(s.headerDelay.Load()); delay > 0 { + time.Sleep(delay) + } + if r.URL.Path == "/latency" { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + chunk := make([]byte, urlTestPayloadChunk) + chunkDelay := time.Duration(s.chunkDelay.Load()) + for range urlTestPayloadChunks { + if _, err := w.Write(chunk); err != nil { + return + } + if canFlush { + flusher.Flush() + } + if chunkDelay > 0 { + time.Sleep(chunkDelay) + } + } +} + +func (s *payloadServer) port(t *testing.T) uint16 { + _, portString, err := net.SplitHostPort(s.server.Listener.Addr().String()) + require.NoError(t, err) + port, err := strconv.ParseUint(portString, 10, 16) + require.NoError(t, err) + return uint16(port) +} + +func (s *payloadServer) setHeaderDelay(delay time.Duration) { + s.headerDelay.Store(int64(delay)) +} + +// urlTestOptions builds a self-contained topology: two shadowsocks servers in the same +// instance, each routing the probe to its own backend, and a urltest group over the two +// matching outbounds. +func urlTestOptions(t *testing.T, env urlTestEnv, backendA *payloadServer, backendB *payloadServer, bandwidth *option.URLTestBandwidthTestOptions, clashAPI bool) option.Options { + options := option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.AddrFrom4([4]byte{127, 0, 0, 1}))), + ListenPort: env.clientPort, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-in-a", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.AddrFrom4([4]byte{127, 0, 0, 1}))), + ListenPort: env.serverPortA, + }, + Method: "none", + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "ss-in-b", + Options: &option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.AddrFrom4([4]byte{127, 0, 0, 1}))), + ListenPort: env.serverPortB, + }, + Method: "none", + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + Tag: "direct", + }, + { + Type: C.TypeShadowsocks, + Tag: "proxy-a", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: env.serverPortA, + }, + Method: "none", + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "proxy-b", + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: env.serverPortB, + }, + Method: "none", + }, + }, + { + Type: C.TypeURLTest, + Tag: "auto", + Options: &option.URLTestOutboundOptions{ + Outbounds: []string{"proxy-a", "proxy-b"}, + URL: urlTestLatencyURL, + Interval: badoption.Duration(urlTestProbeInterval), + IdleTimeout: badoption.Duration(time.Minute), + BandwidthTest: bandwidth, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + urlTestRouteRule("mixed-in", "auto", 0), + urlTestRouteRule("ss-in-a", "direct", backendA.port(t)), + urlTestRouteRule("ss-in-b", "direct", backendB.port(t)), + }, + }, + } + if clashAPI { + options.Experimental = &option.ExperimentalOptions{ + ClashAPI: &option.ClashAPIOptions{ + ExternalController: "127.0.0.1:" + strconv.Itoa(int(env.clashPort)), + }, + } + } + return options +} + +func urlTestRouteRule(inbound string, outbound string, overridePort uint16) option.Rule { + action := option.RouteActionOptions{ + Outbound: outbound, + } + if overridePort != 0 { + action.OverrideAddress = "127.0.0.1" + action.OverridePort = overridePort + } + return option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{inbound}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: action, + }, + }, + } +} + +func urlTestOutbound(t *testing.T, instance *box.Box) *group.URLTest { + t.Helper() + outbound, loaded := instance.Outbound().Outbound("auto") + require.True(t, loaded, "urltest outbound not found") + urlTest, isURLTest := outbound.(*group.URLTest) + require.True(t, isURLTest, "outbound is not a urltest group") + return urlTest +} + +func selectedOutbound(t *testing.T, instance *box.Box) string { + t.Helper() + return urlTestOutbound(t, instance).Now() +} + +// touchGroup sends one connection through the group, which is what starts its probe +// tickers: an untouched group stays suspended and never probes. +func touchGroup(t *testing.T, env urlTestEnv, backend *payloadServer) { + t.Helper() + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", env.clientPort), socks.Version5, "", "") + conn, err := dialer.DialContext(context.Background(), N.NetworkTCP, M.ParseSocksaddrHostPort("127.0.0.1", backend.port(t))) + if err == nil { + conn.Close() + } +} + +func requireSelected(t *testing.T, instance *box.Box, expected string) { + t.Helper() + var last string + if !assert.Eventually(t, func() bool { + last = selectedOutbound(t, instance) + return last == expected + }, urlTestEventually, urlTestEventuallyTick) { + // Eventually evaluates its message eagerly, so report the last observed + // value separately rather than the one from before polling started. + require.FailNowf(t, "selection did not converge", "expected %s, last saw %q", expected, last) + } +} + +// TestURLTestSelectsLowerLatency is the baseline the group has always promised, and +// which had no coverage before this file. +func TestURLTestSelectsLowerLatency(t *testing.T) { + env := newURLTestEnv() + backendFast := startPayloadServer(t, urlTestFastDelay, 0) + backendSlow := startPayloadServer(t, urlTestSlowDelay, 0) + instance := startInstance(t, urlTestOptions(t, env, backendFast, backendSlow, nil, false)) + // Touch the group so its ticker runs. An untouched group probes once at + // PostStart and never retries, which makes a single transient failure permanent. + touchGroup(t, env, backendFast) + requireSelected(t, instance, "proxy-a") +} + +// TestURLTestDropsFailedOutbound covers the failure path: a probe error deletes the +// history entry, which must move selection to the surviving outbound. +func TestURLTestDropsFailedOutbound(t *testing.T) { + env := newURLTestEnv() + backendFast := startPayloadServer(t, urlTestFastDelay, 0) + backendSlow := startPayloadServer(t, urlTestSlowDelay, 0) + instance := startInstance(t, urlTestOptions(t, env, backendFast, backendSlow, nil, false)) + // Touch the group so its ticker runs. An untouched group probes once at + // PostStart and never retries, which makes a single transient failure permanent. + touchGroup(t, env, backendFast) + requireSelected(t, instance, "proxy-a") + + // Take the selected path's backend away; its probe now fails while the other + // still answers. + backendFast.server.Close() + urlTestOutbound(t, instance).CheckOutbounds() + requireSelected(t, instance, "proxy-b") +} + +// TestURLTestToleranceHoldsIncumbent guards against the group flapping between two +// outbounds whose latencies are close, which would churn connections. +func TestURLTestToleranceHoldsIncumbent(t *testing.T) { + env := newURLTestEnv() + backendA := startPayloadServer(t, urlTestFastDelay, 0) + backendB := startPayloadServer(t, urlTestSlowDelay, 0) + options := urlTestOptions(t, env, backendA, backendB, nil, false) + options.Outbounds[3].Options.(*option.URLTestOutboundOptions).Tolerance = 400 + instance := startInstance(t, options) + touchGroup(t, env, backendA) + requireSelected(t, instance, "proxy-a") + + // Make B faster, but by less than the tolerance. The incumbent must hold. + backendA.setHeaderDelay(250 * time.Millisecond) + backendB.setHeaderDelay(urlTestFastDelay) + for range 3 { + urlTestOutbound(t, instance).CheckOutbounds() + } + require.Equal(t, "proxy-a", selectedOutbound(t, instance)) +} + +func TestURLTestReportsAllDelays(t *testing.T) { + env := newURLTestEnv() + backendA := startPayloadServer(t, urlTestFastDelay, 0) + backendB := startPayloadServer(t, 150*time.Millisecond, 0) + instance := startInstance(t, urlTestOptions(t, env, backendA, backendB, nil, false)) + + // URLTest reports only the outbounds it actually re-probed: an entry newer than + // the interval is skipped, and PostStart has just populated both. Wait it out so + // the sweep has something to do. + time.Sleep(urlTestProbeInterval + 200*time.Millisecond) + + result, err := urlTestOutbound(t, instance).URLTest(context.Background()) + require.NoError(t, err) + require.Contains(t, result, "proxy-a") + require.Contains(t, result, "proxy-b") +} + +// TestURLTestBandwidthMeasuresThroughput checks the metric end to end, through a real +// encrypted proxy, and asserts it reaches the Clash API where clients can read it. +func TestURLTestBandwidthMeasuresThroughput(t *testing.T) { + env := newURLTestEnv() + backendA := startPayloadServer(t, urlTestFastDelay, 0) + backendB := startPayloadServer(t, urlTestFastDelay, 0) + instance := startInstance(t, urlTestOptions(t, env, backendA, backendB, &option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: urlTestBandwidthURL, + MaxBytes: urlTestMaxProbeBytes, + Interval: badoption.Duration(urlTestProbeInterval), + Timeout: badoption.Duration(10 * time.Second), + }, true)) + touchGroup(t, env, backendA) + + require.Eventually(t, func() bool { + history := clashProxyHistory(t, env, "proxy-a") + return history != nil && history.Throughput > 0 && history.Bytes > 0 + }, urlTestEventually, urlTestEventuallyTick, "throughput never appeared on the Clash API") + + history := clashProxyHistory(t, env, "proxy-a") + // The cap is a hard limit on what a single probe reads. + require.LessOrEqual(t, history.Bytes, uint32(urlTestMaxProbeBytes)) + // Latency must survive alongside it rather than being overwritten. + require.NotZero(t, history.Time) + // Measuring throughput must not disturb selection under the default strategy. + require.NotEmpty(t, selectedOutbound(t, instance)) +} + +// TestURLTestBandwidthStrategyRanking is the issue's own table made executable: the two +// paths disagree about which is better depending on which property you measure, and the +// strategy decides who wins. +func TestURLTestBandwidthStrategyRanking(t *testing.T) { + for _, testCase := range []struct { + strategy string + expected string + }{ + // proxy-a answers fastest but then crawls. + {C.URLTestStrategyLatency, "proxy-a"}, + // proxy-b is slower to first byte but actually moves data. + {C.URLTestStrategyThroughput, "proxy-b"}, + } { + t.Run(testCase.strategy, func(t *testing.T) { + env := newURLTestEnv() + shaped := startPayloadServer(t, urlTestFastDelay, 40*time.Millisecond) + quick := startPayloadServer(t, 250*time.Millisecond, 0) + instance := startInstance(t, urlTestOptions(t, env, shaped, quick, &option.URLTestBandwidthTestOptions{ + Enabled: true, + URL: urlTestBandwidthURL, + MaxBytes: urlTestMaxProbeBytes, + Interval: badoption.Duration(urlTestProbeInterval), + Timeout: badoption.Duration(20 * time.Second), + Strategy: testCase.strategy, + // One sample, so the assertion does not wait for a median to fill. + Samples: 1, + }, false)) + touchGroup(t, env, shaped) + requireSelected(t, instance, testCase.expected) + }) + } +} + +func clashProxyHistory(t *testing.T, env urlTestEnv, tag string) *adapter.URLTestHistory { + t.Helper() + response, err := http.Get("http://127.0.0.1:" + strconv.Itoa(int(env.clashPort)) + "/proxies/" + tag) + if err != nil { + return nil + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil + } + var proxy struct { + History []*adapter.URLTestHistory `json:"history"` + } + if json.NewDecoder(response.Body).Decode(&proxy) != nil || len(proxy.History) == 0 { + return nil + } + return proxy.History[0] +}