Skip to content

Remote Linux host stays alive but becomes offline after relay WebSocket stalls #6229

Open

Description

@zeroknots

Summary

A persistent Superset Linux host repeatedly becomes offline in the Superset UI/control plane while the VPS, network, host-service process, and terminal processes remain running.

Superset detects the stale relay connection and logs:

[host-service:tunnel] no inbound traffic for 77379ms, forcing reconnect

However, the reconnect path does not restore the host. superset-host.service remains active (running), so systemd cannot detect the failure. Restarting only that service immediately reconnects the host.

This reproduced at least five times during one day of normal use.

Environment

  • Superset CLI and host service: 1.19.0
  • Update check: 1.19.0 is the latest available release
  • Remote OS: Ubuntu 24.04.4 LTS, x86-64
  • Kernel: Linux 6.8.0-136-generic
  • Service manager: systemd user service
  • User lingering: enabled
  • Host type: always-on Hetzner VPS
  • Network: public internet plus Tailscale
  • Superset started with:
[Service]
Type=simple
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.local/bin/superset start --org private
Restart=always
RestartSec=5

Steps to reproduce

  1. Authenticate Superset on an Ubuntu VPS.
  2. Run superset start --org private as a persistent systemd user service.
  3. Confirm that the remote host appears online and use terminals normally.
  4. Leave the service running.
  5. After a variable period—sometimes around 20 minutes, sometimes only a few minutes—the host disappears or becomes offline in Superset.
  6. Check the VPS through an independent SSH or Tailscale connection.

Actual behavior

During the failure:

  • The VPS remains online.
  • SSH and Tailscale remain responsive.
  • CPU, memory, and disk capacity remain healthy.
  • superset-host.service remains active (running).
  • systemd reports NRestarts=0.
  • superset status reports the host process as running.
  • The external Superset control plane reports the host as offline.
  • Existing Superset terminals become inaccessible.

Examples from separate occurrences:

[host-service:tunnel] no inbound traffic for 77379ms, forcing reconnect
[host-service:tunnel] no inbound traffic for 78131ms, forcing reconnect
[host-service:tunnel] no inbound traffic for 80725ms, forcing reconnect
[host-service:tunnel] no inbound traffic for 76045ms, forcing reconnect
[host-service:tunnel] no inbound traffic for 78505ms, forcing reconnect

The client source appears to close the WebSocket after 75 seconds without an inbound application message:

socket.onmessage = (event) => {
  this.lastInboundAt = Date.now();
  this.handleMessage(event.data);
};

if (Date.now() - this.lastInboundAt > 75000) {
  console.warn(
    `[host-service:tunnel] no inbound traffic for ${silentFor}ms, forcing reconnect`
  );
  this.socket.close(4002, "Inbound silence timeout");
}

The relay protocol appears to expect inbound ping messages, to which the host replies with pong.

On one occurrence, the relay TCP connection was still ESTABLISHED, but its send queue had grown to approximately 39 KB:

ESTAB 0 39419 [VPS IPv6]:51768 [relay IPv6]:443

This suggests that the WebSocket or underlying IPv6 TCP path had stopped making progress, but that has not yet been proven as the root cause.

Recovery

Running:

systemctl --user restart superset-host.service

immediately produces:

Connected to relay — machine is now accessible.
[host-service:tunnel] connected to relay for host ...

The host then changes back to online: yes in the external control-plane listing. This recovery worked on every occurrence.

Superset also respawns its recorded terminal sessions after the restart:

[terminal] respawning lost session ...

Expected behavior

When relay traffic becomes stale, Superset should do one of the following:

  1. Successfully establish and register a replacement WebSocket connection.
  2. Detect that reconnection failed and continue retrying with bounded backoff.
  3. Exit with a non-zero status so systemd or another supervisor can restart the process.

The process should not remain alive while the control plane considers the host offline.

The relay should also send application-level keepalive messages frequently enough that a healthy but idle host does not trigger the 75-second inbound-silence timeout.

Additional observations

  • General outbound connectivity continues working during the outage.
  • Tailscale and SSH are unaffected.
  • TCP keepalive settings are unlikely to help because Superset’s timeout is based on application-level inbound WebSocket messages.
  • Missing GitHub CLI authentication previously produced separate PR metadata errors, but those errors did not terminate the process and were not the cause of these relay failures.
  • A temporary watchdog that restarts the service after the stale-tunnel log event reduces downtime, but it does not fix the relay reconnect bug.
  • The host’s relay connection currently resolves over IPv6. A stalled IPv6 path is one possible contributor, but this is not confirmed.

Requested diagnostics/fix

Could the Superset team inspect:

  1. Whether the relay is sending ping messages at the expected interval.
  2. Why closing the socket with code 4002 does not reliably lead to a registered replacement connection.
  3. Whether the old socket remains referenced or blocks connect() through its readyState.
  4. Whether reconnect success should be verified against the control-plane registration state.
  5. Whether the host process should exit after repeated failed relay reconnections.

A bounded reconnect timeout followed by process exit would at least allow the existing Restart=always policy to recover the host automatically.

Activity

  1. github-actions commented on Aug 6, 2026

    @github-actions
    Contributor

    Summary

    The report describes a host-service that logs no inbound traffic for ~77s, forcing reconnect and then never comes back: the process stays alive, superset-host.service stays active (running), and the control plane keeps the host offline until a manual systemctl --user restart. Reading the v1 tunnel client, this is consistent with a real defect: the inbound-silence watchdog's only recovery mechanism is a graceful WebSocket.close(), and every reconnect in that client is scheduled exclusively from the onclose handler. If the TCP path is blackholed (packets dropped rather than reset — which is what a stuck ESTAB with a 39 KB unacked send queue looks like), the close frame can never be flushed, the closing handshake never completes, onclose never fires, and nothing ever calls scheduleReconnect(). The client parks in CLOSING forever.

    Affected code

    • packages/host-service/src/tunnel/tunnel-client.ts:375-391 — the watchdog
    • packages/host-service/src/tunnel/tunnel-client.ts:123-156 — onclose, the only path that calls scheduleReconnect() for an established socket
    • packages/host-service/src/tunnel/tunnel-client.ts:400-418 — scheduleReconnect()
    • packages/host-service/src/serve.ts:108-117 — connectRelay() is called exactly once; there is no outer supervisor that re-drives the tunnel
    • packages/host-service/src/tunnel/connect.ts:22-36 — protocol negotiation; the [host-service:tunnel] prefix (not tunnel-v2) in the reported logs means this host is on the v1 client

    Suspected root cause

    Two lines do the damage together.

    // tunnel-client.ts:377-390
    this.watchdogTimer = setInterval(() => {
      if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;   // ← 378
      const silentFor = Date.now() - this.lastInboundAt;
      if (silentFor > INBOUND_SILENCE_TIMEOUT_MS) {
        console.warn(`...no inbound traffic for ${silentFor}ms, forcing reconnect`);
        try { this.socket.close(4002, "Inbound silence timeout"); } catch {}   // ← 385
      }
    }, WATCHDOG_INTERVAL_MS);
    1. Line 385 requests a graceful close. On a blackholed path the close frame sits in the kernel send queue (matching the reported 39 KB backlog) and the peer's close never arrives, so readyState goes to CLOSING (2) and stays there — potentially for the full TCP retransmission budget (tcp_retries2, ~15 min) or indefinitely. this.socket is never nulled and no reconnect is scheduled.
    2. Line 378 then makes the watchdog a permanent no-op, because readyState is now CLOSING, not OPEN. That explains why the logs show exactly one no inbound traffic line per occurrence with no follow-up — there is no escalation and no second attempt.

    Supporting evidence from elsewhere in the repo:

    • The relay is sending pings on schedule (answers your question 1): apps/relay/src/tunnel.ts:135-142, every 30 s, with a 3-missed-ping (90 s) server-side timeout. 75 s of host-side tolerance leaves room for two lost pings, so the timeout constant itself looks sane — the bug is what happens after it fires.
    • The maintainers already hit the "close frame doesn't reach the peer" failure mode from the other direction, documented at apps/relay/src/tunnel.ts:210-217: "the WS close frame doesn't reliably reach the host within Fly's kill_timeout window (host's TCP socket sees ESTABLISHED with no onclose for 75+ seconds)". The drain path was given an in-band {type:"drain"} message precisely because closing the socket is unreliable — but the watchdog path never got the same treatment.
    • The v2 client does not have this shape: packages/host-service/src/tunnel/tunnel-client-v2.ts:116-125 calls control.reconnect() on partysocket, which (as I read its contract) detaches and redials rather than waiting for a close event. So this is a v1-only regression surface.

    Answering your other questions from the code:

    • (2) Why 4002 doesn't produce a replacement connection: nothing schedules one. scheduleReconnect() is reachable only from onclose (:154), the connect-timeout deadline (:78), and the connect() catch/no-token paths (:94, :168) — none of which run here.
    • (3) Does the old socket block connect()? Not by readyState — the guard at :58-63 only bails on CONNECTING/OPEN, so a CLOSING socket would not block a manual connect(). And onclose is already guarded by if (this.socket !== socket) return; (:124), which means orphaning the wedged socket and dialing a new one is already safe today. The old socket is simply never abandoned.
    • (4) Verifying against control-plane registration: worth doing, and there is a known adjacent race documented in plans/20260804-1815-relay2-durable-objects-canonical.md:51-52 — a dying host's setOnline(false) landing after a replacement's setOnline(true). On the relay, unregister() is guarded against this within one instance (apps/relay/src/tunnel.ts:174), but not across Fly machines: if your host eventually reconnects to a different relay instance and the old instance's socket finally closes, apps/relay/src/tunnel.ts:184 writes setOnline(false) and clobbers the live tunnel's online state. That is a distinct failure mode with the same user-visible symptom, so it's worth ruling in/out (see diagnostics below).
    • (5) Exit after repeated failures: nothing in serve.ts does this today; your Restart=always unit would recover fine if it did.

    A contributing factor for the growing send queue: send() at :189-193 only checks readyState === OPEN and never inspects bufferedAmount, so PTY output keeps being written into a dead socket unbounded.

    Reproduction

    Deterministic (blackhole the path — use DROP, not REJECT; an ICMP reject would close the socket cleanly and not reproduce this):

    1. Start the host service and confirm connected to relay for host ….
    2. Note the peer address: ss -tnp | grep superset-host.
    3. sudo ip6tables -I OUTPUT -d <relay-v6> -p tcp --dport 443 -j DROP (and the matching INPUT rule).
    4. Wait ~80 s. Expected-if-buggy: exactly one no inbound traffic for …ms, forcing reconnect, no reconnecting in Xms (attempt N) line, and ss showing the connection still ESTAB with a growing Send-Q.
    5. Remove the rules. The host should not recover promptly — confirming the wedge.

    Unit-level bun:test sketch against TunnelClient with fake timers: inject a stub WebSocket whose close() sets readyState = 2 (CLOSING) and never dispatches onclose. Open the tunnel, advance the clock past INBOUND_SILENCE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS with no inbound messages, then advance several more minutes and assert the stub's constructor was called a second time. Today it will be called exactly once.

    Diagnostics that would confirm which of the two failure modes you hit: during an outage, run ss -tn '( dport = :443 )' | grep <relay-ip>. One wedged connection with a large Send-Q and no reconnecting in … log ⇒ the watchdog wedge above. A fresh ESTAB connection with an empty queue plus a connected to relay line in the log, while the control plane still says offline ⇒ the cross-instance setOnline(false) clobber instead.

    Suggested fix direction

    In startWatchdog(), stop treating close() as recovery and stop gating the check on OPEN:

    1. On silence timeout, request the close and immediately abandon the socket: clear its handlers, set this.socket = null, stopWatchdog(), cleanupChannels(), then scheduleReconnect(). The this.socket !== socket guard at :124 already makes a late onclose from the orphan harmless. If the runtime exposes terminate(), prefer it over close() so the FD is actually destroyed rather than leaked until TCP gives up.
    2. Alternatively/additionally, arm a short "close did not complete" deadline (~5 s) after the 4002 close, mirroring the existing CONNECT_TIMEOUT_MS pattern at :66-79, and take path 1 when it expires. Also let the watchdog act on CLOSING, not just OPEN, so it can escalate instead of going silent.
    3. Add a backpressure guard in send() (:189): if bufferedAmount stays above a threshold for more than a few seconds, treat the path as dead and force the same reconnect. This would have caught the 39 KB stall long before the 75 s inbound timer.
    4. For the supervisor safety net you asked about: track consecutive failed reconnects (or time since the last open) and process.exit(1) past a bound — cheap, and it makes the existing Restart=always unit sufficient. Worth pairing with a re-registration check so an established-but-unregistered tunnel also counts as a failure.

    Interim workaround on your side: your log-triggered watchdog is the right shape; keying it on the no inbound traffic line without a following connected to relay within ~30 s will avoid restarting on benign transient reconnects.

  2. zeroknots commented on Aug 6, 2026

    @zeroknots
    Author

    I checked out superset-sh/superset and found a concrete reconnect defect in the v1 host tunnel client that matches the observed failure.

    Root cause

    packages/host-service/src/tunnel/tunnel-client.ts:375-390 detects 75 seconds without inbound traffic and calls:

    this.socket.close(4002, "Inbound silence timeout");

    But every reconnect after an established connection is scheduled only by socket.onclose (:123-156). On a blackholed TCP path, the WebSocket close frame remains buffered, the socket stays in CLOSING, and onclose never fires. Therefore scheduleReconnect() is never called and the process remains alive but permanently unreachable.

    This matches the captured state: TCP remained ESTABLISHED with a ~39 KB send queue, the host logged forcing reconnect, no later reconnect log appeared, and restarting the process recovered immediately.

    The existing identity guard in onclose already makes immediate replacement safe:

    if (this.socket !== socket) return;

    So the watchdog should atomically orphan the stale socket before attempting the graceful close:

    1. Capture the stale socket.
    2. Set this.socket = null and this.connecting = false.
    3. Stop the watchdog and clean up local channels.
    4. Best-effort socket.close(...).
    5. Call scheduleReconnect() immediately rather than waiting for onclose.

    I implemented this locally with a regression test whose fake WebSocket enters CLOSING but deliberately never emits close. Before the change, no replacement can be scheduled; with the change, a replacement WebSocket is created. Focused test and Biome check pass.

    There may be a second, distinct cross-relay online-state race, but it is not needed to explain this reproduced stuck-CLOSING failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions