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)} />
</AuthGate>
{#if connectionState.isDisconnected || (connectionState.isConnecting && connectionState.hasConnectedOnce)}
{#if connectionState.shouldShowDisconnectedModal}
<Modal
onClose={() => {}}
zIndex={9999}
@@ -52,6 +52,9 @@
<h2 style="margin-bottom: 12px; color: var(--header-primary);">Connection Lost</h2>
<p style="color: var(--text-normal); margin-bottom: 24px;">
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>
<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.
const db = createSpacetimeDBProvider(untrack(() => builder));
// 1.1 Connection Timeout
// If we stay in "connecting" state for too long without an identity or error,
// we'll force a timeout error to trigger recovery/logout.
// 1.1 Connection Timeout (Initial connection only)
// If the very first connection attempt hangs without identity or error,
// 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);
$effect(() => {
if (!$db.identity && !$db.error) {
const isInitialAttempt = !wasActive && !connectionState.hasConnectedOnce;
if (isInitialAttempt && !$db.identity && !$db.error) {
if (!connectionTimeout) {
connectionTimeout = setTimeout(() => {
console.warn("[Handshake] 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."));
console.warn("[Handshake] Initial connection attempt timed out after 10s.");
connectionState.error = "Connection timeout: Server did not respond to handshake.";
}, 10000);
}
} 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(() => {
if ($db.isActive) {
connectionState.status = "connected";
} else {
connectionState.status = "connecting";
const isActiveNow = $db.isActive;
if (isActiveNow) {
wasActive = true;
connectionState.markConnected();
} else if (wasActive) {
// We HAD a connection and lost it.
connectionState.markDisconnected();
wasActive = false;
}
});
</script>
+49
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from "svelte";
import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
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
const host = getStdbHost();
const dbName = getStdbDbName();
+37
View File
@@ -3,6 +3,7 @@
import { setContext, onMount, untrack } from "svelte";
import { ChatService, Permissions } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import { connectionState } from "../connection.svelte";
import ServerList from "./components/ServerList.svelte";
import ChannelList from "./components/ChannelList.svelte";
import MessageList from "./components/MessageList.svelte";
@@ -140,6 +141,19 @@
</script>
<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) -->
<aside class="left-sidebar-wrapper" class:visible={showSidebar}>
<div class="left-sidebar-top">
@@ -566,4 +580,27 @@
0%, 80%, 100% { transform: translateY(0); }
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>
+6 -2
View File
@@ -27,7 +27,7 @@ export class NavigationService {
const saved = localStorage.getItem(`zep_nav_${myId}`);
if (saved) {
try {
const { serverId, channelId } = JSON.parse(saved);
const { serverId, channelId, threadId } = JSON.parse(saved);
untrack(() => {
if (serverId) {
const bigServerId = BigInt(serverId);
@@ -42,6 +42,9 @@ export class NavigationService {
// It was a DM
this.activeChannelId = BigInt(channelId);
}
if (threadId) {
this.activeThreadId = BigInt(threadId);
}
this.isRestored = true;
});
} catch (e) {
@@ -60,7 +63,8 @@ export class NavigationService {
const state = {
serverId: this.activeServerId?.toString(),
channelId: this.activeChannelId?.toString()
channelId: this.activeChannelId?.toString(),
threadId: this.activeThreadId?.toString()
};
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();
}
connectionState.status = "connected";
connectionState.hasConnectedOnce = true;
connectionState.error = null;
connectionState.markConnected();
if (identityHex !== lastSyncedIdentity) {
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);
const errStr = err.message || "";
const isAuthError =
errStr.includes("401") ||
errStr.toLowerCase().includes("unauthorized") ||
errStr.toLowerCase().includes("timeout") ||
errStr.toLowerCase().includes("identity") ||
errStr.toLowerCase().includes("token");
const lowerErr = errStr.toLowerCase();
// Only treat definitive 401/unauthorized as auth failures.
// Timeouts, DNS failures, and transient network blips should NOT log the user out.
const isAuthError = errStr.includes("401") || lowerErr.includes("unauthorized");
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");
// Only clear TokenStore if we are NOT an OIDC user (Guest mode)
// or if we explicitly failed a refresh.
if (!auth.isAuthenticated) {
TokenStore.clear();
}
@@ -161,6 +157,9 @@ export const handleConnectError = async (err: Error) => {
auth.logout();
return;
}
// Non-auth errors (network, DNS, server down) are recorded and handled
// by the reconnect loop in SpacetimeProvider.svelte.
connectionState.error = err.message;
};
+42
View File
@@ -11,15 +11,57 @@ class ConnectionState {
error = $state<string | null>(null);
hasConnectedOnce = $state(false);
// Reconnection state
reconnectAttempts = $state(0);
isReconnecting = $state(false);
lastDisconnectAt = $state<number | null>(null);
get isConnected() {
return this.status === "connected";
}
get isConnecting() {
return this.status === "connecting";
}
get isDisconnected() {
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();