diff --git a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt index 9136a3000d..5f68b666f0 100644 --- a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt @@ -1,11 +1,13 @@ package com.github.kr328.clash +import com.github.kr328.clash.common.log.Log import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.core.Clash import com.github.kr328.clash.core.model.Proxy import com.github.kr328.clash.design.ProxyDesign import com.github.kr328.clash.design.model.ProxyState import com.github.kr328.clash.util.withClash +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select @@ -92,11 +94,19 @@ class ProxyActivity : BaseActivity() { } is ProxyDesign.Request.UrlTest -> { launch { - withClash { - healthCheck(names[it.index]) - } + try { + withClash { + healthCheck(names[it.index]) + } - design.requests.send(ProxyDesign.Request.Reload(it.index)) + design.requests.send(ProxyDesign.Request.Reload(it.index)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Request url test for `${names[it.index]}`", e) + + design.finishUrlTesting(it.index) + } } } is ProxyDesign.Request.PatchMode -> { diff --git a/core/src/main/golang/native/tunnel.go b/core/src/main/golang/native/tunnel.go index 3e48863c00..a637f5522d 100644 --- a/core/src/main/golang/native/tunnel.go +++ b/core/src/main/golang/native/tunnel.go @@ -71,6 +71,8 @@ func healthCheck(completable unsafe.Pointer, name C.c_string) { tunnel.HealthCheck(name) C.complete(completable, nil) + + C.release_object(completable) }(C.GoString(name)) } diff --git a/core/src/main/golang/native/tunnel/connectivity.go b/core/src/main/golang/native/tunnel/connectivity.go index be716784ea..d469efa2b6 100644 --- a/core/src/main/golang/native/tunnel/connectivity.go +++ b/core/src/main/golang/native/tunnel/connectivity.go @@ -1,14 +1,51 @@ package tunnel import ( + "context" "sync" + "time" "github.com/metacubex/mihomo/adapter/outboundgroup" - "github.com/metacubex/mihomo/constant/provider" + C "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/tunnel" ) +const ( + healthCheckTimeout = 5 * time.Second + healthCheckConcurrency = 10 +) + +// healthCheckURLer is implemented by mihomo proxy providers and exposes the url they test with. +type healthCheckURLer interface { + HealthCheckURL() string +} + +// groupTestURL returns the url the members of the group should be tested with. +// +// It follows the same rules as mihomo's own parser: the provider owned by the group first (a group +// which lists `proxies:` gets one and its url is the group's `url:`, falling back to the core +// default test url), then the first provider with a configured health check url (subscriptions +// referenced by `use:`), and finally the core default test url. +func groupTestURL(g outboundgroup.ProxyGroup) string { + for _, pr := range g.Providers() { + if u, ok := pr.(healthCheckURLer); ok { + if url := u.HealthCheckURL(); url != "" { + return url + } + } + } + + return C.DefaultTestURL +} + +// HealthCheck tests every proxy of the group with groupTestURL(g), so the result can be read back +// afterwards with proxy.LastDelayForTestUrl(groupTestURL(g)). +// +// provider.HealthCheck() is intentionally not used here: it only runs the urls a provider already +// knows about, and it silently does nothing when that url is empty. mihomo builds the reserved +// `default` provider (which backs the auto created GLOBAL group) with an empty health check url +// and never registers a test url on it, so the delay test looked like a no-op for such groups. func HealthCheck(name string) { p := tunnel.Proxies()[name] @@ -25,16 +62,35 @@ func HealthCheck(name string) { return } - wg := &sync.WaitGroup{} + url := groupTestURL(g) + var proxies []C.Proxy for _, pr := range g.Providers() { + proxies = append(proxies, pr.Proxies()...) + } + + log.Debugln("Health checking group `%s` with url `%s` (%d proxies)", name, url, len(proxies)) + + limit := make(chan struct{}, healthCheckConcurrency) + wg := &sync.WaitGroup{} + + for _, px := range proxies { + px := px + + limit <- struct{}{} wg.Add(1) - go func(provider provider.ProxyProvider) { - provider.HealthCheck() + go func() { + defer wg.Done() + defer func() { <-limit }() + + ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout) + defer cancel() - wg.Done() - }(pr) + if _, err := px.URLTest(ctx, url, nil); err != nil { + log.Debugln("Health check `%s` with url `%s`: %s", px.Name(), url, err.Error()) + } + }() } wg.Wait() diff --git a/core/src/main/golang/native/tunnel/proxies.go b/core/src/main/golang/native/tunnel/proxies.go index e750b7adde..81536996e3 100644 --- a/core/src/main/golang/native/tunnel/proxies.go +++ b/core/src/main/golang/native/tunnel/proxies.go @@ -21,6 +21,9 @@ const ( Delay ) +// noDelay is mihomo's sentinel for "no usable delay for this test url". The ui renders it as blank. +const noDelay uint16 = 0xffff + type Proxy struct { Name string `json:"name"` Title string `json:"title"` @@ -98,8 +101,10 @@ func QueryProxyGroup(name string, sortMode SortMode, uiSubtitlePattern *regexp2. return nil } - proxies := convertProxies(g.Proxies(), uiSubtitlePattern) - // proxies := collectProviders(g.Providers(), uiSubtitlePattern) + testURL := groupTestURL(g) + + proxies := convertProxies(g.Proxies(), uiSubtitlePattern, testURL) + // proxies := collectProviders(g.Providers(), uiSubtitlePattern, testURL) switch sortMode { case Title: @@ -165,7 +170,44 @@ func PatchSelector(selector, name string) bool { return true } -func convertProxies(proxies []C.Proxy, uiSubtitlePattern *regexp2.Regexp) []*Proxy { +// lastDelay returns the delay of the proxy for the test url the group is checked with. +// +// mihomo keeps one delay history per test url and LastDelayForTestUrl returns 0xffff unless the +// last test of that exact url was alive, so the url must never be picked at random (map iteration +// order is randomized): ask for the group's own url first, then fall back to any alive url. +func lastDelay(p C.Proxy, testURL string) uint16 { + if testURL != "" { + if delay := p.LastDelayForTestUrl(testURL); delay != noDelay { + return delay + } + } + + var best uint16 + + for _, state := range p.ExtraDelayHistories() { + if !state.Alive || len(state.History) == 0 { + continue + } + + delay := state.History[len(state.History)-1].Delay + + if delay == 0 || delay == noDelay { + continue + } + + if best == 0 || delay < best { + best = delay + } + } + + if best == 0 { + return noDelay + } + + return best +} + +func convertProxies(proxies []C.Proxy, uiSubtitlePattern *regexp2.Regexp, testURL string) []*Proxy { result := make([]*Proxy, 0, 128) for _, p := range proxies { @@ -183,13 +225,6 @@ func convertProxies(proxies []C.Proxy, uiSubtitlePattern *regexp2.Regexp) []*Pro } } } - testURL := "https://www.gstatic.com/generate_204" - for k := range p.ExtraDelayHistories() { - if len(k) > 0 { - testURL = k - break - } - } _, isGroup := p.Adapter().(outboundgroup.ProxyGroup) result = append(result, &Proxy{ @@ -197,14 +232,14 @@ func convertProxies(proxies []C.Proxy, uiSubtitlePattern *regexp2.Regexp) []*Pro Title: strings.TrimSpace(title), Subtitle: strings.TrimSpace(subtitle), Type: p.Type().String(), - Delay: int(p.LastDelayForTestUrl(testURL)), + Delay: int(lastDelay(p, testURL)), IsGroup: isGroup, }) } return result } -func collectProviders(providers []provider.ProxyProvider, uiSubtitlePattern *regexp2.Regexp) []*Proxy { +func collectProviders(providers []provider.ProxyProvider, uiSubtitlePattern *regexp2.Regexp, testURL string) []*Proxy { result := make([]*Proxy, 0, 128) for _, p := range providers { @@ -223,14 +258,6 @@ func collectProviders(providers []provider.ProxyProvider, uiSubtitlePattern *reg } } } - - testURL := "https://www.gstatic.com/generate_204" - for k := range px.ExtraDelayHistories() { - if len(k) > 0 { - testURL = k - break - } - } _, isGroup := px.Adapter().(outboundgroup.ProxyGroup) result = append(result, &Proxy{ @@ -238,7 +265,7 @@ func collectProviders(providers []provider.ProxyProvider, uiSubtitlePattern *reg Title: strings.TrimSpace(title), Subtitle: strings.TrimSpace(subtitle), Type: px.Type().String(), - Delay: int(px.LastDelayForTestUrl(testURL)), + Delay: int(lastDelay(px, testURL)), IsGroup: isGroup, }) } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 7dc7f413d7..5dac781f30 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -159,6 +159,22 @@ class ProxyDesign( updateUrlTestButtonStatus() } + // Called when the url test of a page is over, even if it failed, otherwise the progress + // indicator of the toolbar would spin forever. + suspend fun finishUrlTesting(position: Int) { + withContext(Dispatchers.Main) { + if (binding.pagesView.adapter !is ProxyPageAdapter) + return@withContext + + if (position !in adapter.states.indices) + return@withContext + + adapter.states[position].urlTesting = false + + updateUrlTestButtonStatus() + } + } + private fun updateUrlTestButtonStatus() { if (verticalBottomScrolled || horizontalScrolling || urlTesting) { binding.urlTestFloatView.hide()