2026-07-14, 09:59 PM
(This post was last modified: 2026-07-14, 10:17 PM by jcdick. Edited 1 time in total.)
Github says I need to come here before opening an issue. Technically, re-opening #3461.
Has anyone seen a workaround for the websocket going dead and the client no longer responding to ForcedKeepAlives?
Having the websocket go stale/dead is kind of breaking some things for me.
Thanks for any help anyone can provide. Going through ADB and simulating button clicks and such can be pretty fragile. It would be much easier if I could get Sessions calls working if the device has been idle for longer than fifteen minutes.
Thanks for any information you can provide.
Technical Stuff from what an AI is actually good at:
Has anyone seen a workaround for the websocket going dead and the client no longer responding to ForcedKeepAlives?
Having the websocket go stale/dead is kind of breaking some things for me.
Thanks for any help anyone can provide. Going through ADB and simulating button clicks and such can be pretty fragile. It would be much easier if I could get Sessions calls working if the device has been idle for longer than fifteen minutes.
Thanks for any information you can provide.
Technical Stuff from what an AI is actually good at:
Code:
# WebSocket dies after idle and never reconnects — root cause analysis (code-level)
**Client:** Jellyfin for Android TV (`jellyfin-androidtv`, master @ `b352b9e`)
**SDK:** `org.jellyfin.sdk:jellyfin-core` **1.8.11** (pinned in `gradle/libs.versions.toml`)
**Device:** Amazon Fire TV Stick 4K Max
This has been reported at least twice and both times closed without a diagnosis:
- [#3461 — "Websocket closes after a while and never reopens"](https://github.com/jellyfin/jellyfin-androidtv/issues/3461) (closed, not planned)
- [#4773 — "Websockets very weird behaviour"](https://github.com/jellyfin/jellyfin-androidtv/issues/4773) (closed, stale)
I went through the client and SDK source. The bug is **not** in `jellyfin-androidtv` itself — it's in the socket layer of `jellyfin-sdk-kotlin`, which the TV client consumes with stock settings. There are four defects that compound. The second one is the reason the socket *never comes back*.
---
## 1. The WebSocket has no ping interval, so a dead connection is never detected
`jellyfin-api-okhttp/.../OkHttpFactory.kt:48-53`
```kotlin
public fun createClient(httpClientOptions: HttpClientOptions): okhttp3.OkHttpClient = base.newBuilder()
.followRedirects(httpClientOptions.followRedirects)
.connectTimeout(httpClientOptions.connectTimeout.toJavaDuration())
.callTimeout(httpClientOptions.requestTimeout.toJavaDuration())
.readTimeout(httpClientOptions.socketTimeout.toJavaDuration())
.writeTimeout(httpClientOptions.socketTimeout.toJavaDuration())
.build()
```
`pingInterval` is never set — `grep -rin pingInterval` across the whole SDK returns nothing. OkHttp's default is `0`, i.e. **WebSocket pings are disabled**.
This matters more than it looks, because OkHttp deliberately neutralises the other timeouts for WebSockets:
- `RealConnection.newWebSocketStreams()` sets `socket.soTimeout = 0`, so the configured `readTimeout(30s)` does **not** apply to a WebSocket (correctly — an idle WebSocket is legal).
- `Exchange.newWebSocketStreams()` calls `call.timeoutEarlyExit()`, so `callTimeout(30s)` stops applying once the connection is upgraded.
The result: for a WebSocket, `pingInterval` is the *only* liveness mechanism OkHttp offers, and it's off. If the TCP connection is silently black-holed — Fire TV Wi-Fi power save, router/NAT dropping an idle mapping, CGNAT, a reverse proxy going away — the client's `WebSocketListener` never fires `onFailure`. The SDK still believes it is connected.
The SDK's own application-level KeepAlive doesn't rescue this either, because the send result is discarded (`DefaultSocketApi.publish()` ignores the `Boolean` returned by `OkHttpSocketConnection.send()` at line 111), and `WebSocket.send()` only means "queued in OkHttp's buffer" — not "delivered". Writes into a black-holed socket look successful indefinitely.
**Fix:** `.pingInterval(20.seconds)` (or expose it on `HttpClientOptions`). This is the standard OkHttp remedy and would also keep NAT mappings warm.
---
## 2. A clean close from the server is treated as an intentional shutdown — no reconnect is ever scheduled ⚠️ **this is the "never reopens" bug**
`jellyfin-api/.../DefaultSocketApi.kt:145-151`
```kotlin
// Automatically reconnect when the socket is closed while subscriptions are active
if (_subscriptionCount > 0 && _currentCredentials != null
&& connectionState is SocketConnectionState.Disconnected
&& connectionState.error != null) { // <-- only reconnects when a Throwable is present
socketReconnectPolicy.notifyDisconnected()
...
}
```
Now look at where `Disconnected` comes from — `OkHttpSocketConnection.kt:52-63`:
```kotlin
override fun onClosed(closedWebSocket: WebSocket, code: Int, reason: String) {
logger.debug { "WebSocket has closed, code=$code, reason=$reason" }
_state.value = SocketConnectionState.Disconnected() // error == null
...
}
override fun onFailure(failedWebSocket: WebSocket, t: Throwable, response: Response?) {
logger.warn(t) { "WebSocket has failed" }
_state.value = SocketConnectionState.Disconnected(t) // error != null
...
}
```
So **reconnect only ever happens on `onFailure`** (abrupt/IO error). Any *graceful* close — the peer sending a WebSocket close frame — lands in `onClosed`, produces `Disconnected(error = null)`, and the reconnect block is skipped **even though `_subscriptionCount > 0`**. The socket stays dead until the process is killed or the credentials change. That is precisely the reported symptom, and it explains why users see it "just stop working" with *nothing in the log* — there are no reconnect attempts to log.
The comment on the block says "reconnect when the socket is closed while subscriptions are active", so the `error != null` clause contradicts the stated intent. I'd guess it was added to stop a reconnect loop after a deliberate local `disconnect()` — but the connection layer throws away the information needed to make that distinction. `onClosed` receives the close `code` and `reason` from OkHttp and **discards both**, so `SocketConnectionState.Disconnected(null)` is emitted identically whether *we* closed the socket or *the server* did.
Graceful closes are exactly what an idle connection gets in the real world:
- The Jellyfin **server** closes the socket when it stops receiving KeepAlive messages (see #4 below for why they stop).
- **nginx** in front of Jellyfin closes idle upstream WebSockets at `proxy_read_timeout`, which defaults to **60 seconds**. Same for Traefik/Caddy/HAProxy equivalents.
Note #4773's reporter said the problem occurred with *and* without nginx — consistent with the server-side keepalive close hitting the same code path.
**Fix:** reconnect on `Disconnected` regardless of `error`, and track "did *we* initiate this?" explicitly (set a `closingIntentionally` flag in `disconnect()`), rather than inferring it from the absence of a `Throwable`. Propagating the close `code`/`reason` into `SocketConnectionState.Disconnected` would make this trivial and would also make the logs diagnosable.
---
## 3. Pause/resume race: the socket is closed and nothing restarts it
`DefaultSocketApi.kt:219-224` — when the last subscription goes away:
```kotlin
_subscriptionCount--
val stopping = _subscriptionCount == 0
if (stopping) scope.launch { socketConnection.disconnect() }
```
`DefaultSocketApi.kt:190-195` — the reconnect trigger:
```kotlin
.onStart { reconnect() }
.shareIn(scope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds))
```
`onStart { reconnect() }` only runs when the shared upstream **(re)starts**. With `stopTimeout = 5.seconds`, if subscriptions drop to zero and come back **within 5 seconds**, the sharing coroutine was never stopped, so it is never restarted, so **`reconnect()` never runs** — but `disconnect()` already fired. Live subscriptions, dead socket, no reconnect. And because that close is a local graceful close, defect #2 guarantees nothing recovers it.
There's a second ordering hazard: `disconnect()` is dispatched via `scope.launch`, so it can also land *after* a subsequent `reconnect()` and tear down a freshly-established socket.
The TV client makes this very easy to trigger. `SocketHandler.kt:58-64`:
```kotlin
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { subscribe(this) }
```
bound to `ProcessLifecycleOwner` (`AppModule.kt:109`). **Every** transient loss of RESUMED — Fire TV screensaver, an Alexa overlay, a system dialog, HDMI-CEC input switch — cancels all subscriptions, drops the count to zero and disconnects the socket. `HomeRowsFragment` adds and removes its own subscriptions on top of that.
---
## 4. The KeepAlive ticker is a plain `delay()` loop, so it stops while the device is idle
`DefaultSocketApi.kt:239-251`:
```kotlin
private fun resetKeepAliveTicker(lostTimeout: Duration) {
val delay = lostTimeout / 2
keepAliveTicker?.cancel()
keepAliveTicker = scope.launch(Dispatchers.Unconfined) {
while (true) {
publish(InboundKeepAliveMessage())
delay(delay)
}
}
}
```
This is only ever started from one place — the `ForceKeepAliveMessage` branch of the message filter at line 178. It's a coroutine `delay()` loop, not an `AlarmManager`, so when Android freezes the app process (cached-app freezer / doze — which is exactly what a Fire TV does when it goes to standby or the screensaver kicks in) **the timer stops firing and KeepAlive messages stop going out.**
The server then sees a client that has missed its keepalive window and **closes the socket gracefully** → `onClosed` → `Disconnected(null)` → **defect #2 means no reconnect is scheduled** → the socket is dead for the rest of the process's life.
That is the complete causal chain for "works fine until the Fire Stick idles, then never works again until I force-stop the app."
---
## Bonus: `StateFlow` is used as a message bus, which drops and reorders messages
`OkHttpSocketConnection.kt:41-44`:
```kotlin
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch { _state.value = SocketConnectionState.Message(text) }
}
```
Two problems:
1. `_state` is a `MutableStateFlow` and `SocketConnectionState.Message` is a `data class`. **`StateFlow` conflates equal values** — two identical consecutive messages, and the second is silently dropped. (If a `ForceKeepAliveMessage` is ever the message that gets swallowed, the keepalive ticker never starts, and #2 finishes the job 60 s later.)
2. Each message is dispatched in its **own `scope.launch`**, so messages race to set `.value` and can be delivered **out of order**, or overwritten before a slow collector sees them.
A `MutableSharedFlow(replay = 0, extraBufferCapacity = …)` or a `Channel` is the right primitive here; a `StateFlow` is a *current-value* holder and is not safe to use as an event stream.
Also, `_subscriptionCount` (`Int++/--`) and `_currentSubscriptionTypes` (a plain `mutableMapOf`) are mutated without synchronisation from both `Dispatchers.IO` (SocketHandler) and `Dispatchers.Main` (HomeRowsFragment), plus from `invokeOnCompletion` callbacks that run on arbitrary threads. If that count ever drifts to a spurious zero, the socket is disconnected while subscriptions are still live — another silent-death path.
---
## Suggested fixes, in priority order
1. **`DefaultSocketApi.kt:150`** — reconnect on `Disconnected` regardless of whether `error` is null. Distinguish a locally-initiated close with an explicit flag rather than by the presence of a `Throwable`. *(This alone fixes "never reopens".)*
2. **`OkHttpFactory.kt:48`** — set `.pingInterval(...)` (~20 s) on the client used for WebSockets, and surface it on `HttpClientOptions`. *(Detects half-open sockets and keeps NAT alive.)*
3. **`OkHttpSocketConnection.kt:52`** — propagate the close `code`/`reason` into `SocketConnectionState.Disconnected` instead of discarding them.
4. **`DefaultSocketApi.kt:190-224`** — make the "last subscription gone → disconnect" and "first subscription → reconnect" transitions mutually ordered, so a fast unsubscribe/resubscribe can't leave a disconnected socket with live subscriptions.
5. **`OkHttpSocketConnection.kt:31`** — replace the `StateFlow` message channel with a `SharedFlow`/`Channel`, and drop the per-message `scope.launch`.
## How to tell which path you're hitting, from `adb logcat`
Both defects are silent in different ways, and the log distinguishes them:
- **"WebSocket has closed, code=1000/1001…" and then nothing at all** → defect #2 (graceful close, reconnect skipped). No reconnect attempts will appear.
- **"WebSocket has failed" + repeated "Connecting to ws://…"** → the `onFailure` path, which *does* retry (this is what [#3461](https://github.com/jellyfin/jellyfin-androidtv/issues/3461)'s logs show). If those retries fail with `SocketTimeoutException` it's because the app is backgrounded and Android is throttling its network.
- **No socket log lines at all for a long stretch, then nothing on resume** → defect #1/#4 (dead TCP, never detected).
