Repository navigation
Description
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.0is 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=5Steps to reproduce
- Authenticate Superset on an Ubuntu VPS.
- Run
superset start --org privateas a persistent systemd user service. - Confirm that the remote host appears online and use terminals normally.
- Leave the service running.
- After a variable period—sometimes around 20 minutes, sometimes only a few minutes—the host disappears or becomes offline in Superset.
- 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.serviceremainsactive (running).- systemd reports
NRestarts=0. superset statusreports 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.serviceimmediately 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:
- Successfully establish and register a replacement WebSocket connection.
- Detect that reconnection failed and continue retrying with bounded backoff.
- 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:
- Whether the relay is sending
pingmessages at the expected interval. - Why closing the socket with code
4002does not reliably lead to a registered replacement connection. - Whether the old socket remains referenced or blocks
connect()through itsreadyState. - Whether reconnect success should be verified against the control-plane registration state.
- 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
github-actions commentedon Aug 6, 2026
Summary
The report describes a host-service that logs
no inbound traffic for ~77s, forcing reconnectand then never comes back: the process stays alive,superset-host.servicestaysactive (running), and the control plane keeps the host offline until a manualsystemctl --user restart. Reading the v1 tunnel client, this is consistent with a real defect: the inbound-silence watchdog's only recovery mechanism is a gracefulWebSocket.close(), and every reconnect in that client is scheduled exclusively from theonclosehandler. If the TCP path is blackholed (packets dropped rather than reset — which is what a stuckESTABwith a 39 KB unacked send queue looks like), the close frame can never be flushed, the closing handshake never completes,onclosenever fires, and nothing ever callsscheduleReconnect(). The client parks inCLOSINGforever.Affected code
packages/host-service/src/tunnel/tunnel-client.ts:375-391— the watchdogpackages/host-service/src/tunnel/tunnel-client.ts:123-156—onclose, the only path that callsscheduleReconnect()for an established socketpackages/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 tunnelpackages/host-service/src/tunnel/connect.ts:22-36— protocol negotiation; the[host-service:tunnel]prefix (nottunnel-v2) in the reported logs means this host is on the v1 clientSuspected root cause
Two lines do the damage together.
readyStategoes toCLOSING (2)and stays there — potentially for the full TCP retransmission budget (tcp_retries2, ~15 min) or indefinitely.this.socketis never nulled and no reconnect is scheduled.readyStateis nowCLOSING, notOPEN. That explains why the logs show exactly oneno inbound trafficline per occurrence with no follow-up — there is no escalation and no second attempt.Supporting evidence from elsewhere in the repo:
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.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.packages/host-service/src/tunnel/tunnel-client-v2.ts:116-125callscontrol.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:
scheduleReconnect()is reachable only fromonclose(:154), the connect-timeout deadline (:78), and theconnect()catch/no-token paths (:94,:168) — none of which run here.connect()? Not byreadyState— the guard at:58-63only bails onCONNECTING/OPEN, so aCLOSINGsocket would not block a manualconnect(). Andoncloseis already guarded byif (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.plans/20260804-1815-relay2-durable-objects-canonical.md:51-52— a dying host'ssetOnline(false)landing after a replacement'ssetOnline(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:184writessetOnline(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).serve.tsdoes this today; yourRestart=alwaysunit would recover fine if it did.A contributing factor for the growing send queue:
send()at:189-193only checksreadyState === OPENand never inspectsbufferedAmount, so PTY output keeps being written into a dead socket unbounded.Reproduction
Deterministic (blackhole the path — use
DROP, notREJECT; an ICMP reject would close the socket cleanly and not reproduce this):connected to relay for host ….ss -tnp | grep superset-host.sudo ip6tables -I OUTPUT -d <relay-v6> -p tcp --dport 443 -j DROP(and the matchingINPUTrule).no inbound traffic for …ms, forcing reconnect, noreconnecting in Xms (attempt N)line, andssshowing the connection stillESTABwith a growingSend-Q.Unit-level
bun:testsketch againstTunnelClientwith fake timers: inject a stubWebSocketwhoseclose()setsreadyState = 2 (CLOSING)and never dispatchesonclose. Open the tunnel, advance the clock pastINBOUND_SILENCE_TIMEOUT_MS + WATCHDOG_INTERVAL_MSwith 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 largeSend-Qand noreconnecting in …log ⇒ the watchdog wedge above. A freshESTABconnection with an empty queue plus aconnected to relayline in the log, while the control plane still says offline ⇒ the cross-instancesetOnline(false)clobber instead.Suggested fix direction
In
startWatchdog(), stop treatingclose()as recovery and stop gating the check onOPEN:this.socket = null,stopWatchdog(),cleanupChannels(), thenscheduleReconnect(). Thethis.socket !== socketguard at:124already makes a lateonclosefrom the orphan harmless. If the runtime exposesterminate(), prefer it overclose()so the FD is actually destroyed rather than leaked until TCP gives up.CONNECT_TIMEOUT_MSpattern at:66-79, and take path 1 when it expires. Also let the watchdog act onCLOSING, not justOPEN, so it can escalate instead of going silent.send()(:189): ifbufferedAmountstays 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.open) andprocess.exit(1)past a bound — cheap, and it makes the existingRestart=alwaysunit 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 trafficline without a followingconnected to relaywithin ~30 s will avoid restarting on benign transient reconnects.zeroknots commentedon Aug 6, 2026
I checked out
superset-sh/supersetand 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-390detects 75 seconds without inbound traffic and calls: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 inCLOSING, andonclosenever fires. ThereforescheduleReconnect()is never called and the process remains alive but permanently unreachable.This matches the captured state: TCP remained
ESTABLISHEDwith a ~39 KB send queue, the host loggedforcing reconnect, no later reconnect log appeared, and restarting the process recovered immediately.The existing identity guard in
onclosealready makes immediate replacement safe:So the watchdog should atomically orphan the stale socket before attempting the graceful close:
this.socket = nullandthis.connecting = false.socket.close(...).scheduleReconnect()immediately rather than waiting foronclose.I implemented this locally with a regression test whose fake WebSocket enters
CLOSINGbut deliberately never emitsclose. 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-
CLOSINGfailure.fix(host-service): force tunnel reconnect on inbound silence without …
fix(host-service): force tunnel reconnect on inbound silence without …