better disconnection handling
This commit is contained in:
+4
-1
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user