From 1223f5b8320099ef7c7043ef499fb2a5a442f3ac Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 20 Jun 2026 10:17:42 -0400 Subject: [PATCH] fix(media): HTTP/2 keepalive ping to stop stale-connection image stalls The media OkHttp client keeps 32 connections warm in a 5-minute pool but, unlike the relay client, set no pingInterval. Hosts like blossom.primal.net silently drop idle HTTP/2 connections between feed-scroll bursts. OkHttp then pulls a dead connection from the pool and the request stalls until the read timeout (30s wifi / 90s mobile), which users see as "the first image after a pause takes forever". Device MediaHttp logs showed the signature repeatedly: blossom.primal.net total=30000ms ttfb=-1ms conn=reused error=SocketTimeout Add a 10s HTTP/2 keepalive ping so dead pooled connections are detected in seconds and retryOnConnectionFailure re-issues on a fresh one, mirroring what the relay client already does. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/okhttp/OkHttpClientFactory.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index b41ae1250f..88b65d845a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -75,6 +75,14 @@ class OkHttpClientFactory( .connectionPool(connectionPool) .dns(dns) .eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool, dns)) + // Some media hosts (e.g. blossom.primal.net) silently drop idle HTTP/2 + // connections between the bursts of a feed scroll. Without a keepalive + // ping, OkHttp can pull such a dead connection from the 5-min pool and + // the request stalls until the read timeout (30s wifi / 90s mobile), + // which shows up as "the first image after a pause takes forever". + // An HTTP/2 ping detects the dead connection in seconds and lets + // retryOnConnectionFailure re-issue the request on a fresh one. + .pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS)) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) @@ -128,4 +136,11 @@ class OkHttpClientFactory( } fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) + + companion object { + // HTTP/2 keepalive ping for pooled media connections. Short enough to + // evict a silently-dropped connection well before the read timeout would + // otherwise stall a request for the full 30s/90s. + const val HTTP2_PING_INTERVAL_SECS: Long = 10 + } }