From e0dd953c5a48bbbc64e4b58050bbed4b5b1db420 Mon Sep 17 00:00:00 2001 From: Alexander Anisimov Date: Sun, 10 May 2026 16:09:02 +0300 Subject: [PATCH 1/2] add http ping to mobile --- mobile/mobile.go | 253 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 246 insertions(+), 7 deletions(-) diff --git a/mobile/mobile.go b/mobile/mobile.go index f21f6ab..1ba5811 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -6,7 +6,10 @@ import ( "context" "errors" "fmt" + "io" "log" + "net/http" + "net/url" "sync" "time" @@ -39,16 +42,25 @@ var ( errNotRunning = errors.New("olcRTC is not running") errStoppedBeforeReady = errors.New("olcRTC stopped before becoming ready") errStartTimedOut = errors.New("olcRTC start timed out") + errHTTPPingTimedOut = errors.New("HTTP ping timed out") ) const ( - defaultLink = "direct" - defaultTransport = "vp8channel" - dataTransport = "datachannel" - defaultDNSServer = "1.1.1.1:53" - carrierWBStream = "wbstream" - carrierJazz = "jazz" - roomURLAny = "any" + defaultLink = "direct" + defaultTransport = "vp8channel" + dataTransport = "datachannel" + defaultDNSServer = "1.1.1.1:53" + defaultHTTPPingURL = "https://www.google.com/generate_204" + carrierWBStream = "wbstream" + carrierJazz = "jazz" + roomURLAny = "any" +) + +const ( + httpPingWarmupTimeout = 1500 * time.Millisecond + httpPingSampleTimeout = 1500 * time.Millisecond + httpPingSamples = 3 + httpPingSampleDelay = 80 * time.Millisecond ) //nolint:gochecknoglobals // Mobile bindings expose a singleton runtime controlled by the embedding app. @@ -259,6 +271,233 @@ func Check( } } +// Ping starts an isolated short-lived client, waits until its SOCKS listener is ready, +// performs HTTP requests through that SOCKS tunnel, and returns HTTP latency in milliseconds. +// +// The returned value does not include RTC startup time. It measures only HTTP request latency +// after the tunnel is ready. +func Ping( + carrierName, transportName, roomID, clientID, keyHex string, + socksPort int, + timeoutMillis int, + pingURL string, + vp8FPS int, + vp8BatchSize int, +) (int64, error) { + registerDefaults() + carrierName = normalizeCarrier(carrierName) + transportName = normalizeTransport(transportName) + + if err := validateStartArgs(carrierName, roomID, clientID, keyHex); err != nil { + return 0, err + } + + if timeoutMillis <= 0 { + timeoutMillis = 10000 + } + if pingURL == "" { + pingURL = defaultHTTPPingURL + } + + ctx, cancelFunc := context.WithTimeout( + context.Background(), + time.Duration(timeoutMillis)*time.Millisecond, + ) + defer cancelFunc() + + readyCh := make(chan struct{}) + doneCh := make(chan error, 1) + + var readyOnce sync.Once + + go func() { + doneCh <- runClientWithReady( + ctx, + defaultLink, + transportName, + carrierName, + buildRoomURL(carrierName, roomID), + keyHex, + clientID, + fmt.Sprintf("127.0.0.1:%d", socksPort), + defaultDNSServer, + "", + "", + func() { + readyOnce.Do(func() { + close(readyCh) + }) + }, + 0, + 0, + 0, + "", + "", + 0, + "", + "", + 0, + 0, + clampAtLeastOne(vp8FPS, 120), + clampAtLeastOne(vp8BatchSize, 64), + 0, + 0, + 0, + 0, + ) + }() + + select { + case <-readyCh: + elapsed, err := httpPingThroughSocks( + ctx, + fmt.Sprintf("127.0.0.1:%d", socksPort), + pingURL, + ) + + cancelFunc() + waitForCheckDone(doneCh) + + if err != nil { + return 0, err + } + + return elapsed, nil + + case err := <-doneCh: + if err != nil { + return 0, err + } + + return 0, errStoppedBeforeReady + + case <-ctx.Done(): + cancelFunc() + waitForCheckDone(doneCh) + + return 0, errStartTimedOut + } +} + +func httpPingThroughSocks( + parentCtx context.Context, + socksAddr string, + targetURL string, +) (int64, error) { + if targetURL == "" { + targetURL = defaultHTTPPingURL + } + + if _, err := url.ParseRequestURI(targetURL); err != nil { + return 0, err + } + + proxyURL := &url.URL{ + Scheme: "socks5", + Host: socksAddr, + } + + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + + DisableKeepAlives: false, + MaxIdleConns: 4, + MaxIdleConnsPerHost: 4, + IdleConnTimeout: 10 * time.Second, + + ForceAttemptHTTP2: false, + TLSHandshakeTimeout: httpPingSampleTimeout, + ResponseHeaderTimeout: httpPingSampleTimeout, + ExpectContinueTimeout: 500 * time.Millisecond, + } + defer transport.CloseIdleConnections() + + client := &http.Client{ + Transport: transport, + Timeout: httpPingSampleTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Warm up the SOCKS/TCP/TLS path. This request is intentionally not included + // in the returned latency. + _, _ = singleHTTPPingRequest( + parentCtx, + client, + targetURL, + httpPingWarmupTimeout, + ) + + var best int64 + var lastErr error + + for i := 0; i < httpPingSamples; i++ { + elapsed, err := singleHTTPPingRequest( + parentCtx, + client, + targetURL, + httpPingSampleTimeout, + ) + if err != nil { + lastErr = err + } else if elapsed > 0 && (best == 0 || elapsed < best) { + best = elapsed + } + + if i < httpPingSamples-1 { + time.Sleep(httpPingSampleDelay) + } + } + + if best > 0 { + return best, nil + } + + if lastErr != nil { + return 0, lastErr + } + + return 0, errHTTPPingTimedOut +} + +func singleHTTPPingRequest( + parentCtx context.Context, + client *http.Client, + targetURL string, + timeout time.Duration, +) (int64, error) { + ctx, cancel := context.WithTimeout(parentCtx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return 0, err + } + + req.Header.Set("User-Agent", "Olcbox-Android") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Cache-Control", "no-cache") + + startedAt := time.Now() + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + elapsed := time.Since(startedAt).Milliseconds() + + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode > 399 { + return 0, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + return elapsed, nil +} + func startWithConfig( carrierName, transportName, roomID, clientID, keyHex string, socksPort int, From 7d1a36790406e50f63a7a5d305342eaaa15014d9 Mon Sep 17 00:00:00 2001 From: Alexander Anisimov Date: Sun, 10 May 2026 16:16:24 +0300 Subject: [PATCH 2/2] lint fix --- mobile/mobile.go | 94 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/mobile/mobile.go b/mobile/mobile.go index 1ba5811..5b014c4 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -34,15 +34,16 @@ type LogWriter interface { } var ( - errAlreadyRunning = errors.New("olcRTC already running") - errCarrierRequired = errors.New("carrier is required") - errRoomIDRequired = errors.New("roomID is required") - errClientIDRequired = errors.New("clientID is required") - errKeyHexRequired = errors.New("keyHex is required") - errNotRunning = errors.New("olcRTC is not running") - errStoppedBeforeReady = errors.New("olcRTC stopped before becoming ready") - errStartTimedOut = errors.New("olcRTC start timed out") - errHTTPPingTimedOut = errors.New("HTTP ping timed out") + errAlreadyRunning = errors.New("olcRTC already running") + errCarrierRequired = errors.New("carrier is required") + errRoomIDRequired = errors.New("roomID is required") + errClientIDRequired = errors.New("clientID is required") + errKeyHexRequired = errors.New("keyHex is required") + errNotRunning = errors.New("olcRTC is not running") + errStoppedBeforeReady = errors.New("olcRTC stopped before becoming ready") + errStartTimedOut = errors.New("olcRTC start timed out") + errHTTPPingTimedOut = errors.New("HTTP ping timed out") + errUnexpectedHTTPStatus = errors.New("unexpected HTTP status") ) const ( @@ -384,14 +385,39 @@ func httpPingThroughSocks( socksAddr string, targetURL string, ) (int64, error) { + normalizedURL, err := normalizeHTTPPingURL(targetURL) + if err != nil { + return 0, err + } + + client, closeClient := newHTTPPingClient(socksAddr) + defer closeClient() + + // Warm up the SOCKS/TCP/TLS path. This request is intentionally not included + // in the returned latency. + _, _ = singleHTTPPingRequest( + parentCtx, + client, + normalizedURL, + httpPingWarmupTimeout, + ) + + return bestHTTPPingSample(parentCtx, client, normalizedURL) +} + +func normalizeHTTPPingURL(targetURL string) (string, error) { if targetURL == "" { targetURL = defaultHTTPPingURL } if _, err := url.ParseRequestURI(targetURL); err != nil { - return 0, err + return "", fmt.Errorf("parse HTTP ping URL: %w", err) } + return targetURL, nil +} + +func newHTTPPingClient(socksAddr string) (*http.Client, func()) { proxyURL := &url.URL{ Scheme: "socks5", Host: socksAddr, @@ -410,29 +436,27 @@ func httpPingThroughSocks( ResponseHeaderTimeout: httpPingSampleTimeout, ExpectContinueTimeout: 500 * time.Millisecond, } - defer transport.CloseIdleConnections() client := &http.Client{ Transport: transport, Timeout: httpPingSampleTimeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } - // Warm up the SOCKS/TCP/TLS path. This request is intentionally not included - // in the returned latency. - _, _ = singleHTTPPingRequest( - parentCtx, - client, - targetURL, - httpPingWarmupTimeout, - ) + return client, transport.CloseIdleConnections +} +func bestHTTPPingSample( + parentCtx context.Context, + client *http.Client, + targetURL string, +) (int64, error) { var best int64 var lastErr error - for i := 0; i < httpPingSamples; i++ { + for i := range httpPingSamples { elapsed, err := singleHTTPPingRequest( parentCtx, client, @@ -441,8 +465,8 @@ func httpPingThroughSocks( ) if err != nil { lastErr = err - } else if elapsed > 0 && (best == 0 || elapsed < best) { - best = elapsed + } else { + best = bestPositiveLatency(best, elapsed) } if i < httpPingSamples-1 { @@ -461,6 +485,18 @@ func httpPingThroughSocks( return 0, errHTTPPingTimedOut } +func bestPositiveLatency(currentBest, next int64) int64 { + if next <= 0 { + return currentBest + } + + if currentBest == 0 || next < currentBest { + return next + } + + return currentBest +} + func singleHTTPPingRequest( parentCtx context.Context, client *http.Client, @@ -472,7 +508,7 @@ func singleHTTPPingRequest( req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) if err != nil { - return 0, err + return 0, fmt.Errorf("create HTTP ping request: %w", err) } req.Header.Set("User-Agent", "Olcbox-Android") @@ -483,16 +519,18 @@ func singleHTTPPingRequest( resp, err := client.Do(req) if err != nil { - return 0, err + return 0, fmt.Errorf("perform HTTP ping request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() elapsed := time.Since(startedAt).Milliseconds() _, _ = io.Copy(io.Discard, resp.Body) - if resp.StatusCode < 200 || resp.StatusCode > 399 { - return 0, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusPermanentRedirect { + return 0, fmt.Errorf("%w: %d", errUnexpectedHTTPStatus, resp.StatusCode) } return elapsed, nil