• Login
  • Register
  • Login Register
    Login
    Username/Email:
    Password:
    Or login with a social network below
  • Forum
  • Website
  • GitHub
  • Status
  • Translation
  • Features
  • Team
  • Rules
  • Help
  • Feeds
User Links
  • Login
  • Register
  • Login Register
    Login
    Username/Email:
    Password:
    Or login with a social network below

    Useful Links Forum Website GitHub Status Translation Features Team Rules Help Feeds
    Jellyfin Forum Support Troubleshooting Networking & Access Dead websockets

     
    • 0 Vote(s) - 0 Average

    Dead websockets

    After idle, websocket goes dead
    jcdick
    Offline

    Junior Member

    Posts: 7
    Threads: 3
    Joined: 2024 Jun
    Reputation: 0
    Country:United States
    #1
    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:


    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).
    TheDreadPirate
    Online

    Community Moderator

    Posts: 15,880
    Threads: 11
    Joined: 2023 Jun
    Reputation: 478
    Country:United States
    #2
    2026-07-14, 10:47 PM
    Can you describe the jellyfin server side of your setup? Are you using a reverse proxy?
    Jellyfin 10.11.11 (Docker)
    Debian 13 w/Xanmod amd64v3 LTS kernel
    AMD Ryzen 5500 w/32GB DDR4
    Intel Arc A380
    OS drive - SK Hynix P41 1TB
    ZFS Storage pool
        vdev1 - 6x WD Red Pro 6TB CMR in RAIDZ1
        vdev2 - 3x WD Red Pro 18TB CMR in RAIDZ1
    [Image: GitHub%20Sponsors-grey?logo=github]
    jcdick
    Offline

    Junior Member

    Posts: 7
    Threads: 3
    Joined: 2024 Jun
    Reputation: 0
    Country:United States
    #3
    2026-07-14, 11:54 PM (This post was last modified: 2026-07-15, 12:39 AM by jcdick. Edited 4 times in total.)
    In this case, no.  The client is pointing directly to the IP:8096.

    It's apparently due to the Kotlin SDK having no ping interval and OkHttp eating other timeout handling.

    What happens is, if you start your client after either a device power cycle or an app force-close and watch something, everything is great. If you stop playback and walk away, after a few minutes the server log starts filling with 


    Code:
    [2026-07-15 00:16:22.673 +00:00] [INF] Sending ForceKeepAlive message to 1 inactive WebSockets.
    [2026-07-15 00:17:10.672 +00:00] [INF] Sending ForceKeepAlive message to 1 inactive WebSockets.
    [2026-07-15 00:17:58.672 +00:00] [INF] Sending ForceKeepAlive message to 1 inactive WebSockets.
    [2026-07-15 00:18:46.672 +00:00] [INF] Sending ForceKeepAlive message to 1 inactive WebSockets.

    Until the server decides to stop because the websocket is dead.

    Apparently, in the Kotlin SDK, pingInterval is set to 0 and OkHttp doesn't have any other timeout handling, and so it becomes stale and nothing using websockets works until playback is started again to refresh the websocket.  However, that doesn't work when the Sessions API is the method of playback.
    « Next Oldest | Next Newest »

    Users browsing this thread: 1 Guest(s)


    • View a Printable Version
    • Subscribe to this thread
    Forum Jump:

    Home · Team · Help · Contact
    © Designed by D&D - Powered by MyBB
    L


    Jellyfin

    The Free Software Media System

    Linear Mode
    Threaded Mode