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
+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.