better disconnection handling

This commit is contained in:
2026-05-05 20:22:58 -04:00
parent 47243e52ec
commit 853658f05c
7 changed files with 170 additions and 25 deletions
+4 -1
View File
@@ -41,7 +41,7 @@
<ChatContainer onShowServerSettings={() => handleToggleServerSettings(true)} /> <ChatContainer onShowServerSettings={() => handleToggleServerSettings(true)} />
</AuthGate> </AuthGate>
{#if connectionState.isDisconnected || (connectionState.isConnecting && connectionState.hasConnectedOnce)} {#if connectionState.shouldShowDisconnectedModal}
<Modal <Modal
onClose={() => {}} onClose={() => {}}
zIndex={9999} zIndex={9999}
@@ -52,6 +52,9 @@
<h2 style="margin-bottom: 12px; color: var(--header-primary);">Connection Lost</h2> <h2 style="margin-bottom: 12px; color: var(--header-primary);">Connection Lost</h2>
<p style="color: var(--text-normal); margin-bottom: 24px;"> <p style="color: var(--text-normal); margin-bottom: 24px;">
Your connection to the SpacetimeDB server was interrupted. Your connection to the SpacetimeDB server was interrupted.
{#if connectionState.lastDisconnectAt}
<br/><small>Offline for {Math.round((Date.now() - connectionState.lastDisconnectAt) / 1000)}s</small>
{/if}
</p> </p>
<div style="display: flex; flex-direction: column; gap: 12px;"> <div style="display: flex; flex-direction: column; gap: 12px;">
+22 -11
View File
@@ -19,17 +19,19 @@
// We untrack the builder here because SpacetimeProvider handles remounting on builder changes. // We untrack the builder here because SpacetimeProvider handles remounting on builder changes.
const db = createSpacetimeDBProvider(untrack(() => builder)); const db = createSpacetimeDBProvider(untrack(() => builder));
// 1.1 Connection Timeout // 1.1 Connection Timeout (Initial connection only)
// If we stay in "connecting" state for too long without an identity or error, // If the very first connection attempt hangs without identity or error,
// we'll force a timeout error to trigger recovery/logout. // surface a timeout so the user isn't staring at a spinner forever.
// During reconnection, the backoff loop in SpacetimeProvider handles retries.
let connectionTimeout = $state<any>(null); let connectionTimeout = $state<any>(null);
$effect(() => { $effect(() => {
if (!$db.identity && !$db.error) { const isInitialAttempt = !wasActive && !connectionState.hasConnectedOnce;
if (isInitialAttempt && !$db.identity && !$db.error) {
if (!connectionTimeout) { if (!connectionTimeout) {
connectionTimeout = setTimeout(() => { connectionTimeout = setTimeout(() => {
console.warn("[Handshake] Connection attempt timed out after 10s."); console.warn("[Handshake] Initial connection attempt timed out after 10s.");
handleConnectError(new Error("Connection timeout: Server did not respond to handshake. This may be due to an expired token or network issues.")); connectionState.error = "Connection timeout: Server did not respond to handshake.";
}, 10000); }, 10000);
} }
} else { } else {
@@ -95,12 +97,21 @@
} }
}); });
// Update global connection status for UI visibility // 5. Connection State Tracking
// Track whether we have ever been active so we can distinguish
// "initial connecting" from "was connected then dropped".
let wasActive = $state(false);
$effect(() => { $effect(() => {
if ($db.isActive) { const isActiveNow = $db.isActive;
connectionState.status = "connected";
} else { if (isActiveNow) {
connectionState.status = "connecting"; wasActive = true;
connectionState.markConnected();
} else if (wasActive) {
// We HAD a connection and lost it.
connectionState.markDisconnected();
wasActive = false;
} }
}); });
</script> </script>
+49
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { auth } from "./auth/auth.svelte"; import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config"; import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte"; import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
@@ -34,6 +35,54 @@
} }
}); });
// 2. Managed Reconnect Loop
// When the connection drops, wait with exponential backoff before remounting.
// This prevents hammering the server and gives transient blips time to resolve.
let reconnectTimeout = $state<any>(null);
$effect(() => {
if (connectionState.status === "disconnected") {
const attempt = connectionState.reconnectAttempts;
// Backoff: 1s, 2s, 4s, 8s, 16s ... capped at 30s
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
reconnectTimeout = setTimeout(() => {
console.log(`[SpacetimeProvider] Attempting reconnect #${attempt + 1} after ${delay}ms...`);
connectionState.markReconnecting();
untrack(() => {
const currentToken = auth.user?.id_token;
builder = connectionBuilder(currentToken);
lastUsedOidcToken = currentToken;
providerKey += 1;
});
}, delay);
}
return () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
};
});
// Allow manual force-reconnect (e.g. from a "Reconnect Now" button)
export function forceReconnectNow() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
console.log("[SpacetimeProvider] Forcing immediate reconnect...");
connectionState.markReconnecting();
untrack(() => {
const currentToken = auth.user?.id_token;
builder = connectionBuilder(currentToken);
lastUsedOidcToken = currentToken;
providerKey += 1;
});
}
// Reactive labels for the loading screen // Reactive labels for the loading screen
const host = getStdbHost(); const host = getStdbHost();
const dbName = getStdbDbName(); const dbName = getStdbDbName();
+37
View File
@@ -3,6 +3,7 @@
import { setContext, onMount, untrack } from "svelte"; import { setContext, onMount, untrack } from "svelte";
import { ChatService, Permissions } from "./services/chat.svelte"; import { ChatService, Permissions } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte"; import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import { connectionState } from "../connection.svelte";
import ServerList from "./components/ServerList.svelte"; import ServerList from "./components/ServerList.svelte";
import ChannelList from "./components/ChannelList.svelte"; import ChannelList from "./components/ChannelList.svelte";
import MessageList from "./components/MessageList.svelte"; import MessageList from "./components/MessageList.svelte";
@@ -140,6 +141,19 @@
</script> </script>
<div class="chat-container"> <div class="chat-container">
<!-- Reconnecting Banner (grace period only) -->
{#if connectionState.isReconnecting && !connectionState.shouldShowDisconnectedModal}
<div class="reconnecting-banner">
<i class="fas fa-circle-notch fa-spin"></i>
<span>
Reconnecting
{#if connectionState.reconnectAttempts > 0}
(attempt {connectionState.reconnectAttempts})
{/if}...
</span>
</div>
{/if}
<!-- 1. Left Sidebar (Servers + Channels) --> <!-- 1. Left Sidebar (Servers + Channels) -->
<aside class="left-sidebar-wrapper" class:visible={showSidebar}> <aside class="left-sidebar-wrapper" class:visible={showSidebar}>
<div class="left-sidebar-top"> <div class="left-sidebar-top">
@@ -566,4 +580,27 @@
0%, 80%, 100% { transform: translateY(0); } 0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-4px); } 40% { transform: translateY(-4px); }
} }
.reconnecting-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10000;
background-color: var(--status-warning);
color: #000;
padding: 6px 12px;
font-size: 0.8rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
animation: banner-slide-in 0.3s ease;
}
@keyframes banner-slide-in {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
</style> </style>
+6 -2
View File
@@ -27,7 +27,7 @@ export class NavigationService {
const saved = localStorage.getItem(`zep_nav_${myId}`); const saved = localStorage.getItem(`zep_nav_${myId}`);
if (saved) { if (saved) {
try { try {
const { serverId, channelId } = JSON.parse(saved); const { serverId, channelId, threadId } = JSON.parse(saved);
untrack(() => { untrack(() => {
if (serverId) { if (serverId) {
const bigServerId = BigInt(serverId); const bigServerId = BigInt(serverId);
@@ -42,6 +42,9 @@ export class NavigationService {
// It was a DM // It was a DM
this.activeChannelId = BigInt(channelId); this.activeChannelId = BigInt(channelId);
} }
if (threadId) {
this.activeThreadId = BigInt(threadId);
}
this.isRestored = true; this.isRestored = true;
}); });
} catch (e) { } catch (e) {
@@ -60,7 +63,8 @@ export class NavigationService {
const state = { const state = {
serverId: this.activeServerId?.toString(), serverId: this.activeServerId?.toString(),
channelId: this.activeChannelId?.toString() channelId: this.activeChannelId?.toString(),
threadId: this.activeThreadId?.toString()
}; };
localStorage.setItem(`zep_nav_${myId}`, JSON.stringify(state)); localStorage.setItem(`zep_nav_${myId}`, JSON.stringify(state));
}); });
+10 -11
View File
@@ -97,9 +97,7 @@ export const handleConnect = (conn: DbConnection, identity: any, token: string,
TokenStore.clear(); TokenStore.clear();
} }
connectionState.status = "connected"; connectionState.markConnected();
connectionState.hasConnectedOnce = true;
connectionState.error = null;
if (identityHex !== lastSyncedIdentity) { if (identityHex !== lastSyncedIdentity) {
console.log("[Handshake] New identity detected, syncing server-side auth metadata..."); console.log("[Handshake] New identity detected, syncing server-side auth metadata...");
@@ -120,20 +118,18 @@ export const handleConnectError = async (err: Error) => {
console.log("[Handshake] Error connecting to SpacetimeDB:", err); console.log("[Handshake] Error connecting to SpacetimeDB:", err);
const errStr = err.message || ""; const errStr = err.message || "";
const isAuthError = const lowerErr = errStr.toLowerCase();
errStr.includes("401") ||
errStr.toLowerCase().includes("unauthorized") || // Only treat definitive 401/unauthorized as auth failures.
errStr.toLowerCase().includes("timeout") || // Timeouts, DNS failures, and transient network blips should NOT log the user out.
errStr.toLowerCase().includes("identity") || const isAuthError = errStr.includes("401") || lowerErr.includes("unauthorized");
errStr.toLowerCase().includes("token");
if (isAuthError) { if (isAuthError) {
console.warn(`[Handshake] Auth or Timeout error for ${host}:${dbName}: ${errStr}`); console.warn(`[Handshake] Auth error for ${host}:${dbName}: ${errStr}`);
const { auth } = await import("./auth/auth.svelte"); const { auth } = await import("./auth/auth.svelte");
// Only clear TokenStore if we are NOT an OIDC user (Guest mode) // Only clear TokenStore if we are NOT an OIDC user (Guest mode)
// or if we explicitly failed a refresh.
if (!auth.isAuthenticated) { if (!auth.isAuthenticated) {
TokenStore.clear(); TokenStore.clear();
} }
@@ -161,6 +157,9 @@ export const handleConnectError = async (err: Error) => {
auth.logout(); auth.logout();
return; return;
} }
// Non-auth errors (network, DNS, server down) are recorded and handled
// by the reconnect loop in SpacetimeProvider.svelte.
connectionState.error = err.message; connectionState.error = err.message;
}; };
+42
View File
@@ -11,15 +11,57 @@ class ConnectionState {
error = $state<string | null>(null); error = $state<string | null>(null);
hasConnectedOnce = $state(false); hasConnectedOnce = $state(false);
// Reconnection state
reconnectAttempts = $state(0);
isReconnecting = $state(false);
lastDisconnectAt = $state<number | null>(null);
get isConnected() { get isConnected() {
return this.status === "connected"; return this.status === "connected";
} }
get isConnecting() { get isConnecting() {
return this.status === "connecting"; return this.status === "connecting";
} }
get isDisconnected() { get isDisconnected() {
return this.status === "disconnected" && this.hasConnectedOnce; return this.status === "disconnected" && this.hasConnectedOnce;
} }
/**
* Only show the scary full-page modal after 5 seconds of being offline.
* During the grace period, a subtle banner is shown instead.
*/
get shouldShowDisconnectedModal() {
if (!this.hasConnectedOnce) return false;
if (this.status === "disconnected" && this.lastDisconnectAt) {
return Date.now() - this.lastDisconnectAt > 5000;
}
return false;
}
markConnected() {
this.status = "connected";
this.error = null;
this.reconnectAttempts = 0;
this.isReconnecting = false;
this.lastDisconnectAt = null;
this.hasConnectedOnce = true;
}
markDisconnected() {
if (this.status !== "disconnected") {
this.status = "disconnected";
this.lastDisconnectAt = Date.now();
this.isReconnecting = true;
}
}
markReconnecting() {
this.status = "connecting";
this.isReconnecting = true;
this.reconnectAttempts += 1;
}
} }
export const connectionState = new ConnectionState(); export const connectionState = new ConnectionState();