perf(network): mobile stability and data-usage fixes from network audit

Stability:
- transient timeouts no longer classified as ISP blocking; they fall
  through to retry backoff (hard blocks - DNS/RST/cert - still abort)
- connectivity change now closes idle Go connections in every network
  mode (debounced), so pooled sockets from the old interface are not
  reused after a wifi/cellular handoff
- download body reads get a 60s stall watchdog that cancels and
  surfaces a retryable network error instead of hanging; distinct
  from user cancellation
- ResponseHeaderTimeout 45s on all transports; IdleConnTimeout 90->60s;
  dial timeout 30->10s; retry backoff gains full jitter; Retry-After
  honored on 5xx and Deezer 429

Data usage / speed:
- cover downloads deduplicated with singleflight plus a 24MB/15min
  in-memory cache keyed by final URL (album batches fetched the same
  1800px cover once per track before)
- song.link availability cached (30min positive / 5min negative)
  in front of the 9-req/min rate limiter
- uTLS Cloudflare path now pools one HTTP/2 connection per host with
  a shared TLS session cache instead of a full handshake per request

Deezer artist track-count N+1 kept: counts feed the discography
download UI and are already amortized by the artist TTL cache.
This commit is contained in:
zarzet
2026-07-13 08:39:05 +07:00
parent 8579f68554
commit 2fa4aa5b70
12 changed files with 682 additions and 44 deletions
+100
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
@@ -52,6 +54,104 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Final URL: %s", downloadURL)
data, err := fetchCoverCached(downloadURL)
if err != nil {
return nil, err
}
// Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy.
return append([]byte(nil), data...), nil
}
const (
coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute
)
type coverCacheEntry struct {
data []byte
expiresAt time.Time
}
type coverInflightCall struct {
wg sync.WaitGroup
data []byte
err error
}
var (
coverMu sync.Mutex
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes int
coverInflight = map[string]*coverInflightCall{}
coverFetch = fetchCoverBytes
)
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
// requests for the same URL into a single fetch (singleflight) and caching
// results in memory for the duration of an album batch. The returned slice is
// shared; callers must copy before mutating.
func fetchCoverCached(downloadURL string) ([]byte, error) {
coverMu.Lock()
if e, ok := coverCache[downloadURL]; ok {
if time.Now().Before(e.expiresAt) {
data := e.data
coverMu.Unlock()
return data, nil
}
delete(coverCache, downloadURL)
coverCacheBytes -= len(e.data)
}
if call, ok := coverInflight[downloadURL]; ok {
coverMu.Unlock()
call.wg.Wait()
return call.data, call.err
}
call := &coverInflightCall{}
call.wg.Add(1)
coverInflight[downloadURL] = call
coverMu.Unlock()
data, err := coverFetch(downloadURL)
call.data, call.err = data, err
if err == nil {
coverCachePut(downloadURL, data)
}
call.wg.Done()
coverMu.Lock()
delete(coverInflight, downloadURL)
coverMu.Unlock()
return data, err
}
func coverCachePut(downloadURL string, data []byte) {
if len(data) == 0 || len(data) > coverCacheMaxBytes {
return
}
coverMu.Lock()
defer coverMu.Unlock()
if e, ok := coverCache[downloadURL]; ok {
coverCacheBytes -= len(e.data)
}
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
coverCacheBytes += len(data)
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
var oldestKey string
var oldest time.Time
first := true
for k, e := range coverCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
coverCacheBytes -= len(coverCache[oldestKey].data)
delete(coverCache, oldestKey)
}
}
func fetchCoverBytes(downloadURL string) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("GET", downloadURL, nil)
+96
View File
@@ -0,0 +1,96 @@
package gobackend
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func resetCoverCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverInflight = map[string]*coverInflightCall{}
coverCacheBytes = 0
coverMu.Unlock()
}
func TestFetchCoverCachedSingleflight(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
entered := make(chan struct{})
release := make(chan struct{})
coverFetch = func(string) ([]byte, error) {
if atomic.AddInt32(&calls, 1) == 1 {
close(entered)
}
<-release
return []byte("coverbytes"), nil
}
const url = "https://cdn.example/cover_max.jpg"
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("leader fetch error: %v", err)
}
}()
<-entered // leader has registered inflight and is blocked in coverFetch
wg.Add(1)
go func() {
defer wg.Done()
if _, err := fetchCoverCached(url); err != nil {
t.Errorf("follower fetch error: %v", err)
}
}()
close(release)
wg.Wait()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
}
}
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
var calls int32
coverFetch = func(string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("data"), nil
}
const url = "https://cdn.example/ttl.jpg"
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("first fetch error: %v", err)
}
// second call served from cache
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("second fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected cache hit, got %d fetches", got)
}
// expire the entry and confirm a refetch
coverMu.Lock()
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
coverMu.Unlock()
if _, err := fetchCoverCached(url); err != nil {
t.Fatalf("third fetch error: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
}
}
+6 -1
View File
@@ -1269,6 +1269,10 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
var apiErr *deezerAPIError
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
delay = apiErr.RetryAfter
}
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1292,6 +1296,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
type deezerAPIError struct {
StatusCode int
Body string
RetryAfter time.Duration
}
func (e *deezerAPIError) Error() string {
@@ -1329,7 +1334,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any)
}
if resp.StatusCode != http.StatusOK {
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
}
return json.Unmarshal(body, dst)
+44
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"context"
"fmt"
"net"
"net/http"
@@ -276,6 +277,49 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
return req.WithContext(initDownloadCancel(itemID))
}
// downloadStallTimeout is how long a download may go without receiving a single
// byte before the stall watchdog aborts it. A dead radio mid-transfer otherwise
// blocks on Body.Read until the 24h client timeout with no error and no retry.
const downloadStallTimeout = 60 * time.Second
// stallWatchdog cancels an in-flight download when no data arrives within
// timeout. It wraps the request context in a child cancel so firing it does NOT
// set the user-cancel flag (isDownloadCancelled stays false) — a stall is a
// distinct, retryable condition. Call reset() after every successful Read and
// stop() when the transfer ends.
type stallWatchdog struct {
cancel context.CancelFunc
timer *time.Timer
timeout time.Duration
stalled atomic.Bool
}
func bindStallWatchdog(req *http.Request, timeout time.Duration) (*http.Request, *stallWatchdog) {
ctx, cancel := context.WithCancel(req.Context())
w := &stallWatchdog{cancel: cancel, timeout: timeout}
w.timer = time.AfterFunc(timeout, func() {
w.stalled.Store(true)
cancel()
})
return req.WithContext(ctx), w
}
func (w *stallWatchdog) reset() { w.timer.Reset(w.timeout) }
// stop halts the timer and releases the child context so a completed download
// leaks neither a pending timer nor a live cancel func.
func (w *stallWatchdog) stop() {
w.timer.Stop()
w.cancel()
}
// stallError is returned when the watchdog fires. The message is deliberately
// free of "cancel" and worded to classify as retryable network failure, so the
// fallback layer retries instead of treating it as a user cancellation.
func (r *extensionRuntime) stallError() goja.Value {
return r.jsError("download stalled: no data received for %ds (network timeout)", int(downloadStallTimeout.Seconds()))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
+23 -1
View File
@@ -203,6 +203,8 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
return r.jsError("%s", err.Error())
}
req = r.bindDownloadCancelContext(req)
req, wd := bindStallWatchdog(req, downloadStallTimeout)
defer wd.stop()
for k, v := range headers {
req.Header.Set(k, v)
@@ -213,6 +215,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
resp, err := client.Do(req)
if err != nil {
if wd.stalled.Load() {
return r.stallError()
}
return r.jsError("%s", err.Error())
}
defer resp.Body.Close()
@@ -259,6 +264,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
wd.reset()
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
@@ -283,6 +289,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
if er != nil {
if er != io.EOF {
if wd.stalled.Load() {
return r.stallError()
}
return r.jsError("failed to read response: %v", er)
}
break
@@ -332,6 +341,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
return r.jsError("chunked: probe request error: %v", err)
}
probeReq = r.bindDownloadCancelContext(probeReq)
probeReq, wd := bindStallWatchdog(probeReq, downloadStallTimeout)
defer wd.stop()
stallCtx := probeReq.Context()
probeReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" { // Don't copy any existing Range header
@@ -342,6 +354,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
probeResp, err := client.Do(probeReq)
if err != nil {
if wd.stalled.Load() {
return r.stallError()
}
return r.jsError("chunked: probe error: %v", err)
}
io.Copy(io.Discard, probeResp.Body)
@@ -420,7 +435,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
if err != nil {
return r.jsError("chunked: request error at offset %d: %v", offset, err)
}
chunkReq = r.bindDownloadCancelContext(chunkReq)
chunkReq = chunkReq.WithContext(stallCtx)
chunkReq.Header.Set("User-Agent", ua)
for k, v := range headers {
if k != "Range" {
@@ -431,6 +446,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
chunkResp, chunkErr = client.Do(chunkReq)
if chunkErr != nil {
if wd.stalled.Load() {
return r.stallError()
}
if retry < maxRetries-1 {
time.Sleep(time.Duration(retry+1) * time.Second)
continue
@@ -459,6 +477,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
for {
nr, er := chunkResp.Body.Read(buf)
if nr > 0 {
wd.reset()
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
@@ -487,6 +506,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
if er != nil {
if er != io.EOF {
chunkResp.Body.Close()
if wd.stalled.Load() {
return r.stallError()
}
return r.jsError("failed to read chunk at offset %d: %v", offset, er)
}
break
@@ -0,0 +1,54 @@
package gobackend
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestStallWatchdogCancelsOnNoData verifies the watchdog aborts a transfer that
// stops sending bytes, marks itself stalled (not user-cancelled), and does so
// after resetting on the initial byte.
func TestStallWatchdogCancelsOnNoData(t *testing.T) {
block := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
w.Write([]byte("x"))
w.(http.Flusher).Flush()
<-block // simulate a dead radio mid-transfer: stop sending
}))
defer srv.Close()
defer close(block)
req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
req, wd := bindStallWatchdog(req, 150*time.Millisecond)
defer wd.stop()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
buf := make([]byte, 32)
var readErr error
for {
n, er := resp.Body.Read(buf)
if n > 0 {
wd.reset()
}
if er != nil {
readErr = er
break
}
}
if readErr == nil {
t.Fatal("expected read error from stall cancel")
}
if !wd.stalled.Load() {
t.Fatalf("watchdog did not mark stalled; err=%v", readErr)
}
}
+61 -8
View File
@@ -65,14 +65,15 @@ var (
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -84,14 +85,15 @@ var sharedTransport = &http.Transport{
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -103,14 +105,15 @@ var extensionAPITransport = &http.Transport{
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -276,7 +279,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil {
lastErr = err
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
if isHardConnectivityBlock(err) {
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
}
@@ -331,6 +334,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if resp.StatusCode >= 500 {
resp.Body.Close()
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
delay = retryAfter
}
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
@@ -346,9 +352,20 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// jitterFloat returns a fraction in [0,1); overridable in tests for
// deterministic backoff assertions.
var jitterFloat = rand.Float64
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay)
capped := min(nextDelay, config.MaxDelay)
// Full jitter: spread retries between InitialDelay and the capped
// exponential ceiling to avoid synchronized thundering-herd retries.
if capped <= config.InitialDelay {
return capped
}
span := capped - config.InitialDelay
return config.InitialDelay + time.Duration(jitterFloat()*float64(span))
}
// Returns 0 if the header is missing or invalid so callers can keep their
@@ -524,6 +541,42 @@ func isTLSHandshakeOrResetError(err error) bool {
return false
}
// isHardConnectivityBlock reports transport failures that signal an active
// block (DNS not found, connection refused/reset, TLS/cert MITM) and should
// abort retries immediately. Timeouts and deadline-exceeded are treated as
// transient and excluded so they fall through to normal retry backoff.
func isHardConnectivityBlock(err error) bool {
if err == nil {
return false
}
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Timeout() {
return false
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Timeout() {
return false
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED, syscall.ECONNRESET:
return true
}
}
}
return isTLSHandshakeOrResetError(err)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
+3
View File
@@ -112,9 +112,12 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
t.Fatalf("ReadResponseBody = %q/%v", body, err)
}
origJitter := jitterFloat
jitterFloat = func() float64 { return 1 }
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
t.Fatal("calculateNextDelay mismatch")
}
jitterFloat = origJitter
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
+80 -23
View File
@@ -4,27 +4,38 @@ package gobackend
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// utlsSessionCache is shared by every uTLS handshake so TLS 1.3 tickets enable
// resumption (fewer round-trips) across requests and hosts.
var utlsSessionCache = utls.NewLRUClientSessionCache(0)
// utlsTransport dials with a Chrome TLS fingerprint and pools one healthy HTTP/2
// connection per host, re-dialing when it dies (e.g. after a network switch).
type utlsTransport struct {
dialer *net.Dialer
h2 *http2.Transport
mu sync.Mutex
conns map[string]*http2.ClientConn
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 30 * Second,
Timeout: 10 * Second,
KeepAlive: 30 * Second,
},
h2: &http2.Transport{},
conns: make(map[string]*http2.ClientConn),
}
}
@@ -34,48 +45,94 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
host := req.URL.Hostname()
port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port)
addr := net.JoinHostPort(host, t.getPort(req.URL))
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if cc := t.cachedConn(addr); cc != nil {
resp, err := cc.RoundTrip(req)
if err != nil {
t.invalidate(addr, cc)
}
return resp, err
}
tlsConn, proto, err := t.dial(req.Context(), host, addr)
if err != nil {
return nil, err
}
if proto != "h2" {
// HTTP/1.1: single-use conn closed once the body is drained.
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
cc, err := t.h2.NewClientConn(tlsConn)
if err != nil {
tlsConn.Close()
return nil, err
}
cc = t.storeConn(addr, cc)
return cc.RoundTrip(req)
}
// dial opens a TCP connection and completes the Chrome-fingerprint TLS handshake,
// returning the connection and negotiated ALPN protocol.
func (t *utlsTransport) dial(ctx context.Context, host, addr string) (*utls.UConn, string, error) {
conn, err := t.dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, "", err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
ClientSessionCache: utlsSessionCache,
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
return nil, "", err
}
return tlsConn, tlsConn.ConnectionState().NegotiatedProtocol, nil
}
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" {
h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
},
AllowHTTP: false,
DisableCompression: false,
}
return h2Transport.RoundTrip(req)
func (t *utlsTransport) cachedConn(addr string) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if cc := t.conns[addr]; cc != nil && cc.CanTakeNewRequest() {
return cc
}
return nil
}
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
func (t *utlsTransport) invalidate(addr string, cc *http2.ClientConn) {
t.mu.Lock()
if t.conns[addr] == cc {
delete(t.conns, addr)
}
t.mu.Unlock()
}
return transport.RoundTrip(req)
// storeConn caches cc, but if a concurrent dial already cached a healthy conn for
// addr it discards the freshly built cc (no in-flight requests) and returns the
// existing one, avoiding a leaked connection.
func (t *utlsTransport) storeConn(addr string, cc *http2.ClientConn) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if existing := t.conns[addr]; existing != nil && existing.CanTakeNewRequest() {
cc.Close()
return existing
}
t.conns[addr] = cc
return cc
}
func (t *utlsTransport) getPort(u *url.URL) string {
+96 -2
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"sync"
"time"
)
type SongLinkClient struct {
@@ -294,18 +295,111 @@ func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPl
return songLinkResp.LinksByPlatform, nil
}
const (
trackAvailabilityCacheTTL = 30 * time.Minute
trackAvailabilityNegCacheTTL = 5 * time.Minute
trackAvailabilityCacheMax = 500
)
type trackAvailabilityCacheEntry struct {
availability *TrackAvailability
err bool
expiresAt time.Time
}
var (
trackAvailabilityCacheMu sync.Mutex
trackAvailabilityCache = map[string]trackAvailabilityCacheEntry{}
)
// CheckTrackAvailability resolves platform availability for a track. Results are
// cached in memory (keyed by region + spotifyID/ISRC) to spare the song.link
// path its 9 req/min budget. This is an extra layer beneath the Dart-side
// cached-invoke and is safe: lookups are idempotent. Negative results use a
// shorter TTL so transient failures recover quickly.
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
isrc = strings.ToUpper(strings.TrimSpace(isrc))
var idKey string
switch {
case spotifyTrackID != "":
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
idKey = "spotify:" + spotifyTrackID
case isrc != "":
return s.checkTrackAvailabilityFromISRC(isrc)
idKey = "isrc:" + isrc
default:
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
}
key := GetSongLinkRegion() + "|" + idKey
if cached, hit, cachedErr := trackAvailabilityCacheLookup(key); hit {
if cachedErr {
return nil, fmt.Errorf("track availability unavailable (cached)")
}
return cloneTrackAvailability(cached), nil
}
var availability *TrackAvailability
var err error
switch {
case spotifyTrackID != "":
availability, err = s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
default:
availability, err = s.checkTrackAvailabilityFromISRC(isrc)
}
trackAvailabilityCacheStore(key, availability, err)
if err != nil {
return nil, err
}
return cloneTrackAvailability(availability), nil
}
func cloneTrackAvailability(a *TrackAvailability) *TrackAvailability {
if a == nil {
return nil
}
c := *a
return &c
}
func trackAvailabilityCacheLookup(key string) (*TrackAvailability, bool, bool) {
trackAvailabilityCacheMu.Lock()
defer trackAvailabilityCacheMu.Unlock()
e, ok := trackAvailabilityCache[key]
if !ok {
return nil, false, false
}
if time.Now().After(e.expiresAt) {
delete(trackAvailabilityCache, key)
return nil, false, false
}
return e.availability, true, e.err
}
func trackAvailabilityCacheStore(key string, availability *TrackAvailability, err error) {
ttl := trackAvailabilityCacheTTL
if err != nil {
ttl = trackAvailabilityNegCacheTTL
}
trackAvailabilityCacheMu.Lock()
defer trackAvailabilityCacheMu.Unlock()
if _, exists := trackAvailabilityCache[key]; !exists && len(trackAvailabilityCache) >= trackAvailabilityCacheMax {
var oldestKey string
var oldest time.Time
first := true
for k, e := range trackAvailabilityCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
delete(trackAvailabilityCache, oldestKey)
}
trackAvailabilityCache[key] = trackAvailabilityCacheEntry{
availability: availability,
err: err != nil,
expiresAt: time.Now().Add(ttl),
}
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
+66
View File
@@ -4,7 +4,9 @@ import (
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -23,7 +25,14 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
}
}
func resetTrackAvailabilityCache() {
trackAvailabilityCacheMu.Lock()
trackAvailabilityCache = map[string]trackAvailabilityCacheEntry{}
trackAvailabilityCacheMu.Unlock()
}
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
resetTrackAvailabilityCache()
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
@@ -65,6 +74,7 @@ func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
}
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
resetTrackAvailabilityCache()
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
@@ -115,6 +125,7 @@ func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
}
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
resetTrackAvailabilityCache()
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
@@ -155,6 +166,61 @@ func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *tes
}
}
func TestCheckTrackAvailabilityCachesResult(t *testing.T) {
resetTrackAvailabilityCache()
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
var calls int32
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
atomic.AddInt32(&calls, 1)
body := `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/cachedid","Deezer":"https://www.deezer.com/track/111"}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}),
},
}
first, err := client.CheckTrackAvailability("cachedid", "")
if err != nil {
t.Fatalf("first CheckTrackAvailability() error = %v", err)
}
second, err := client.CheckTrackAvailability("cachedid", "")
if err != nil {
t.Fatalf("second CheckTrackAvailability() error = %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 network call with caching, got %d", got)
}
if first == second {
t.Fatal("expected cache to return a distinct clone, got same pointer")
}
if second.DeezerID != "111" {
t.Fatalf("cached DeezerID = %q, want 111", second.DeezerID)
}
}
func TestCheckTrackAvailabilityNegativeCacheTTL(t *testing.T) {
resetTrackAvailabilityCache()
entry := trackAvailabilityCacheEntry{err: true, expiresAt: time.Now().Add(-time.Second)}
key := GetSongLinkRegion() + "|spotify:expiredneg"
trackAvailabilityCacheMu.Lock()
trackAvailabilityCache[key] = entry
trackAvailabilityCacheMu.Unlock()
if _, hit, _ := trackAvailabilityCacheLookup(key); hit {
t.Fatal("expired negative entry should not be a cache hit")
}
}
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
+53 -9
View File
@@ -196,6 +196,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final Set<String> _ensuredDirs = {};
int _idleProgressPollTick = 0;
bool _networkPausedByWifiOnly = false;
List<ConnectivityResult>? _lastConnectivityResults;
DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _connectionCleanupDebounce = Duration(seconds: 2);
String? _lastServiceTrackName;
String? _lastServiceArtistName;
String? _lastServiceStatus;
@@ -1829,18 +1832,59 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
final shouldResume = _networkPausedByWifiOnly && state.isPaused;
_stopConnectivityMonitoring();
// Keep monitoring active in every mode while downloading so idle
// connections are still recycled when the network switches.
if (state.isProcessing) {
_networkPausedByWifiOnly = false;
_startConnectivityMonitoring();
} else {
_stopConnectivityMonitoring();
}
if (shouldResume) {
resumeQueue();
}
}
void _handleConnectivityResults(List<ConnectivityResult> results) {
final settings = ref.read(settingsProvider);
if (settings.downloadNetworkMode != 'wifi_only') {
_handleDownloadNetworkModeChanged(settings.downloadNetworkMode);
/// Closes idle backend HTTP connections when the connectivity set changes
/// (e.g. WiFi <-> cellular). Stale sockets bound to the old interface would
/// otherwise be reused, stalling the first request after a network switch.
/// Applies to every download network mode. Fire-and-forget with a light
/// debounce so network flapping does not spam the bridge.
void _maybeCleanupOnNetworkChange(List<ConnectivityResult> results) {
final previous = _lastConnectivityResults;
final unchanged =
previous != null && _connectivitySetEquals(previous, results);
_lastConnectivityResults = List<ConnectivityResult>.unmodifiable(results);
if (previous == null || unchanged) return;
final now = DateTime.now();
if (now.difference(_lastConnectionCleanupAt) < _connectionCleanupDebounce) {
return;
}
_lastConnectionCleanupAt = now;
_log.i('Network changed, closing idle backend connections');
unawaited(
PlatformBridge.cleanupConnections().catchError((Object e) {
_log.w('Failed to clean up connections after network change: $e');
}),
);
}
bool _connectivitySetEquals(
List<ConnectivityResult> a,
List<ConnectivityResult> b,
) {
final setA = a.toSet();
final setB = b.toSet();
return setA.length == setB.length && setA.containsAll(setB);
}
void _handleConnectivityResults(List<ConnectivityResult> results) {
_maybeCleanupOnNetworkChange(results);
final settings = ref.read(settingsProvider);
if (settings.downloadNetworkMode != 'wifi_only') return;
if (_hasWifiConnection(results)) {
if (_networkPausedByWifiOnly && state.isPaused) {
@@ -1921,11 +1965,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(isProcessing: false, isPaused: true);
return;
}
_networkPausedByWifiOnly = false;
_startConnectivityMonitoring();
} else {
_stopConnectivityMonitoring();
}
// Monitor in every mode so idle connections are recycled on a network
// switch, even though only wifi_only pauses the queue.
_networkPausedByWifiOnly = false;
_startConnectivityMonitoring();
if (await _tryProcessQueueWithAndroidNativeWorker(settings)) {
return;