diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 3c0d4084e..d07f59213 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -425,7 +425,10 @@ function isTooltipDisabled(): boolean { async function toggle() { const node = props.node; - if (node.isLoading) return; + if (node.isLoading) { + if (node.type !== "connection") return; + node.isLoading = false; + } emit("search-toggle", node); const wasExpanded = !!node.isExpanded; diff --git a/apps/desktop/src/lib/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebarSearchTree.ts index 564700eaa..bf24eb200 100644 --- a/apps/desktop/src/lib/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebarSearchTree.ts @@ -51,6 +51,7 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe node: { ...node, children, + isLoading: node.type === "connection" ? false : node.isLoading, isExpanded: children.length > 0 && !collapsedIds.has(node.id), }, score: selfMatch?.score ?? 0, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index bca891083..56df06bbf 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -56,6 +56,9 @@ import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/compl const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; const CONNECTION_HEALTH_CHECK_TTL_MS = 2000; +const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000; +const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000; +const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000; const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy"; const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)"; function sidebarObjectGroupPageSize(): number { @@ -273,6 +276,57 @@ export const useConnectionStore = defineStore("connection", () => { return typeof checkedAt === "number" && Date.now() - checkedAt < CONNECTION_HEALTH_CHECK_TTL_MS; } + function clearConnectionNodeLoading(connectionId: string) { + const node = findNode(treeNodes.value, connectionId); + if (node) node.isLoading = false; + } + + function metadataLoadTimeoutMs(config?: ConnectionConfig): number { + const queryTimeoutSecs = Number(config?.query_timeout_secs); + if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS; + const boundedTimeoutSecs = Number.isFinite(queryTimeoutSecs) && queryTimeoutSecs > 0 ? queryTimeoutSecs + 5 : 35; + return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, boundedTimeoutSecs * 1000); + } + + async function withMetadataLoadTimeout(connectionId: string, promise: Promise, label: string): Promise { + const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId)); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Connection timed out while loading ${label} after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`)); + }, timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + async function withDisconnectRequestTimeout(connectionId: string, promise: Promise): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + void promise.catch((error) => { + if (timedOut) console.warn("[DBX][connection:disconnect-late-error]", { connectionId, error }); + }); + try { + await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + console.warn("[DBX][connection:disconnect-timeout]", { connectionId, timeoutMs: DISCONNECT_REQUEST_TIMEOUT_MS }); + resolve(); + }, DISCONNECT_REQUEST_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + function recordConnectionError(connectionId: string, error: unknown): string { const message = connectionErrorMessage(error); setConnectionError(connectionId, message); @@ -281,6 +335,7 @@ export const useConnectionStore = defineStore("connection", () => { function markConnectionLost(connectionId: string, error: unknown) { connectedIds.value.delete(connectionId); + clearConnectionNodeLoading(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; recordConnectionError(connectionId, error); @@ -305,12 +360,22 @@ export const useConnectionStore = defineStore("connection", () => { const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); let timedOut = false; let timer: ReturnType | undefined; - void promise.catch((error) => { - if (!timedOut) return; - const current = connectionErrors.value[config.id]; - if (current !== timeoutMessage) return; - setConnectionError(config.id, connectionAttemptOriginalErrorMessage(timeoutMessage, connectionErrorMessage(error))); - }); + void promise.then( + (connectionId) => { + if (!timedOut) return; + const cleanupConnectionId = typeof connectionId === "string" && connectionId ? connectionId : config.id; + if (connectedIds.value.has(cleanupConnectionId)) return; + void api.disconnectDb(cleanupConnectionId).catch((error) => { + console.warn("[DBX][connection:timeout-cleanup-failed]", { connectionId: cleanupConnectionId, error }); + }); + }, + (error) => { + if (!timedOut) return; + const current = connectionErrors.value[config.id]; + if (current !== timeoutMessage) return; + setConnectionError(config.id, connectionAttemptOriginalErrorMessage(timeoutMessage, connectionErrorMessage(error))); + }, + ); try { return await Promise.race([ promise, @@ -928,7 +993,7 @@ export const useConnectionStore = defineStore("connection", () => { async function disconnect(connectionId: string) { const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true; - await api.disconnectDb(connectionId); + await withDisconnectRequestTimeout(connectionId, api.disconnectDb(connectionId)); clearConnectionError(connectionId); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); @@ -946,6 +1011,7 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionHealthCheck(connectionId); const node = findNode(treeNodes.value, connectionId); if (node) { + node.isLoading = false; node.isExpanded = false; node.children = []; } @@ -1043,7 +1109,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } } - const [databases, schemas] = await Promise.all([api.listDatabases(connectionId), api.listSchemas(connectionId, "main")]); + const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]); const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); @@ -1057,7 +1123,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } } - const schemas = await api.listSchemas(connectionId, effectiveDb); + const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb), "schemas"); const visibleSchemas = filterDatabaseNamesForConnection(schemas, config); const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({ id: `${connectionId}:${s}:${s}`, @@ -1080,7 +1146,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } } - const databases = await api.listDatabases(connectionId); + const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"); const visibleNames = filterDatabaseNamesForConnection( databases.map((database) => database.name), config, @@ -1092,7 +1158,7 @@ export const useConnectionStore = defineStore("connection", () => { includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), }); if (config?.db_type === "sqlserver") { - const linkedServers = await api.listSqlServerLinkedServers(connectionId).catch(() => []); + const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []); const linkedDatabase = sqlServerLinkedRuntimeDatabase(config); databaseNodes.push({ ...sqlServerLinkedRootNode(connectionId, linkedDatabase), @@ -1130,7 +1196,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isLoading = true; try { await ensureConnected(connectionId); - const dbs = await api.redisListDatabases(connectionId); + const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases"); const config = getConfig(connectionId); const visibleNames = filterVisibleDatabaseNames( dbs.map((db) => String(db.db)), @@ -1209,7 +1275,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; - const tenants = await api.mqListTenants(connectionId); + const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants"); const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim())); setChildren( node, @@ -1307,7 +1373,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isLoading = true; try { await ensureConnected(connectionId); - const dbs = await api.mongoListDatabases(connectionId); + const dbs = await withMetadataLoadTimeout(connectionId, api.mongoListDatabases(connectionId), "MongoDB databases"); const config = getConfig(connectionId); const visibleDbs = filterDatabaseNamesForConnection(dbs, config); setChildren( @@ -1342,7 +1408,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isLoading = true; try { await ensureConnected(connectionId); - const indices = await api.elasticsearchListIndices(connectionId); + const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices"); setChildren( node, withSavedSqlRoot( @@ -1374,7 +1440,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isLoading = true; try { await ensureConnected(connectionId); - const collections = await api.vectorListCollections(connectionId); + const collections = await withMetadataLoadTimeout(connectionId, api.vectorListCollections(connectionId), "vector collections"); setChildren( node, withSavedSqlRoot( diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 0abea0957..2cec30648 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -126,6 +126,7 @@ pub struct AppState { pub connections: Arc>>, keepalive_tasks: Arc>>>, pool_activity: Arc>>, + connection_attempts: RwLock>, pub configs: RwLock>, pub running_queries: RunningQueries, pub tunnels: TunnelManager, @@ -351,6 +352,7 @@ impl AppState { connections: Arc::new(RwLock::new(HashMap::new())), keepalive_tasks: Arc::new(RwLock::new(HashMap::new())), pool_activity: Arc::new(RwLock::new(HashMap::new())), + connection_attempts: RwLock::new(HashMap::new()), configs: RwLock::new(HashMap::new()), running_queries: RunningQueries::default(), tunnels: TunnelManager::new(), @@ -423,6 +425,48 @@ impl AppState { } } + pub async fn begin_connection_attempt(&self, connection_id: &str) -> u64 { + let mut attempts = self.connection_attempts.write().await; + let next = attempts.get(connection_id).copied().unwrap_or(0).wrapping_add(1); + attempts.insert(connection_id.to_string(), next); + next + } + + pub async fn supersede_connection_attempt(&self, connection_id: &str) { + self.begin_connection_attempt(connection_id).await; + } + + async fn connection_attempt_is_current(&self, connection_id: &str, attempt: u64) -> bool { + self.connection_attempts.read().await.get(connection_id).copied() == Some(attempt) + } + + async fn ensure_current_connection_attempt(&self, connection_id: &str, attempt: Option) -> Result<(), String> { + let Some(attempt) = attempt else { + return Ok(()); + }; + if self.connection_attempt_is_current(connection_id, attempt).await { + Ok(()) + } else { + Err("Connection attempt was superseded by a newer attempt".to_string()) + } + } + + pub async fn insert_connection_pool_for_attempt( + &self, + connection_id: &str, + attempt: u64, + pool_key: String, + pool: PoolKind, + config: &ConnectionConfig, + ) -> Result<(), String> { + if let Err(err) = self.ensure_current_connection_attempt(connection_id, Some(attempt)).await { + close_pool_kind(pool).await; + return Err(err); + } + self.insert_connection_pool(pool_key, pool, config).await; + Ok(()) + } + async fn start_keepalive_task(&self, pool_key: &str, pool: &PoolKind, config: &ConnectionConfig) { let interval_secs = config.keepalive_interval_secs; let idle_timeout_secs = config.idle_timeout_secs; @@ -537,11 +581,30 @@ impl AppState { self.get_or_create_pool_for_session(connection_id, database, None).await } + pub async fn get_or_create_pool_for_connection_attempt( + &self, + connection_id: &str, + database: Option<&str>, + attempt: u64, + ) -> Result { + self.get_or_create_pool_for_session_inner(connection_id, database, None, Some(attempt)).await + } + pub async fn get_or_create_pool_for_session( &self, connection_id: &str, database: Option<&str>, client_session_id: Option<&str>, + ) -> Result { + self.get_or_create_pool_for_session_inner(connection_id, database, client_session_id, None).await + } + + async fn get_or_create_pool_for_session_inner( + &self, + connection_id: &str, + database: Option<&str>, + client_session_id: Option<&str>, + connection_attempt: Option, ) -> Result { let db_type = { let configs = self.configs.read().await; @@ -693,6 +756,7 @@ impl AppState { if self.connections.read().await.contains_key(&pool_key) { return Ok(pool_key); } + self.ensure_current_connection_attempt(connection_id, connection_attempt).await?; self.insert_connection_pool(pool_key.clone(), PoolKind::MongoDb(client), &db_config) .await; return Ok(pool_key); @@ -924,6 +988,10 @@ impl AppState { } }; + if let Err(err) = self.ensure_current_connection_attempt(connection_id, connection_attempt).await { + close_pool_kind(pool).await; + return Err(err); + } self.insert_connection_pool(pool_key.clone(), pool, &db_config).await; Ok(pool_key) } @@ -2679,6 +2747,48 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn stale_connection_attempt_cannot_replace_newer_pool() { + let (state, dir) = test_app_state().await; + let mut config = mysql_config(None); + config.name = "SQLite".to_string(); + config.db_type = DatabaseType::Sqlite; + config.host = dir.join("current.db").to_string_lossy().to_string(); + let old_attempt = state.begin_connection_attempt("conn").await; + let new_attempt = state.begin_connection_attempt("conn").await; + let current_pool = + db::sqlite::connect_path_create_if_missing(&dir.join("current.db").to_string_lossy()).await.unwrap(); + let stale_pool = + db::sqlite::connect_path_create_if_missing(&dir.join("stale.db").to_string_lossy()).await.unwrap(); + + state + .insert_connection_pool_for_attempt( + "conn", + new_attempt, + "conn".to_string(), + PoolKind::Sqlite(current_pool), + &config, + ) + .await + .unwrap(); + + let result = state + .insert_connection_pool_for_attempt( + "conn", + old_attempt, + "conn".to_string(), + PoolKind::Sqlite(stale_pool), + &config, + ) + .await; + + assert!(result.is_err()); + let conns = state.connections.read().await; + assert!(matches!(conns.get("conn"), Some(PoolKind::Sqlite(_)))); + assert_eq!(conns.len(), 1); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn jdbc_plugin_env_uses_managed_jre_when_installed() { let dir = std::env::temp_dir().join(format!("dbx-core-jdbc-managed-jre-{}", uuid::Uuid::new_v4())); diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index fd4b62046..722e1469b 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -67,12 +67,13 @@ pub async fn connect_db( let config = body.config; let app = &state.app; let connection_id = config.id.clone(); + let attempt = app.begin_connection_attempt(&connection_id).await; - app.remove_connection_pools(&connection_id).await; + app.remove_connection_pools_detached(&connection_id).await; app.reset_connection_transport_for_config(&connection_id, &config).await; app.configs.write().await.insert(connection_id.clone(), config.clone()); - app.get_or_create_pool(&connection_id, None).await.map_err(AppError)?; + app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await.map_err(AppError)?; Ok(Json(connection_id)) } @@ -101,7 +102,8 @@ pub async fn disconnect_db( ) -> Result, AppError> { let app = &state.app; - app.remove_connection_pools(&body.connection_id).await; + app.supersede_connection_attempt(&body.connection_id).await; + app.remove_connection_pools_detached(&body.connection_id).await; app.nacos_registry.drop_connection(&body.connection_id).await; #[cfg(feature = "mq-admin")] app.mq_registry.drop_connection(&body.connection_id).await; @@ -225,7 +227,7 @@ async fn drop_mq_adapters_for_connection_ids(_state: &WebState, _connection_ids: async fn remove_connection_pools_for_connection_ids(state: &WebState, connection_ids: &[String]) { for connection_id in connection_ids { - state.app.remove_connection_pools(connection_id).await; + state.app.remove_connection_pools_detached(connection_id).await; } } diff --git a/packages/app-tests/connectionStoreErrorState.test.ts b/packages/app-tests/connectionStoreErrorState.test.ts index c515aac4b..ac174c00d 100644 --- a/packages/app-tests/connectionStoreErrorState.test.ts +++ b/packages/app-tests/connectionStoreErrorState.test.ts @@ -84,6 +84,50 @@ test("failed disconnect keeps the existing connection error", async () => { } }); +test("hanging disconnect request still clears local connection state", async () => { + vi.useFakeTimers(); + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (String(input) === "/api/connection/disconnect") { + return new Promise(() => {}); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + store.activeConnectionId = "conn-1"; + store.recordConnectionError("conn-1", new Error("metadata failed")); + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + isLoading: true, + isExpanded: true, + children: [{ id: "conn-1:db", label: "db", type: "database", connectionId: "conn-1", database: "db" }], + }); + + const disconnectPromise = store.disconnect("conn-1"); + await vi.advanceTimersByTimeAsync(5000); + await disconnectPromise; + + assert.equal(store.connectionErrors["conn-1"], undefined); + assert.equal(store.connectedIds.has("conn-1"), false); + assert.equal(store.activeConnectionId, null); + assert.equal(store.treeNodes[0].isLoading, false); + assert.equal(store.treeNodes[0].isExpanded, false); + assert.deepEqual(store.treeNodes[0].children, []); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("query errors mentioning connection do not mark the connection disconnected", async () => { const restoreStorage = installMemoryStorage(); try { @@ -142,11 +186,20 @@ test("explicit lost-connection marker clears state without relying on error text const store = useConnectionStore(); store.addEphemeralConnection(conn("conn-1")); store.activeConnectionId = "conn-1"; + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + isLoading: true, + children: [], + }); store.markConnectionLost("conn-1", new Error("连接可能已断开,请刷新数据重试")); assert.equal(store.connectedIds.has("conn-1"), false); assert.equal(store.activeConnectionId, null); + assert.equal(store.treeNodes[0].isLoading, false); assert.equal(store.connectionErrors["conn-1"], "连接可能已断开,请刷新数据重试"); await new Promise((resolve) => setTimeout(resolve, 0)); } finally { @@ -189,3 +242,175 @@ test("late original connect errors replace the generic timeout detail", async () restoreStorage(); } }); + +test("late original connect success is disconnected after the UI timeout", async () => { + vi.useFakeTimers(); + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + const disconnected: string[] = []; + globalThis.fetch = (async (input, init) => { + if (String(input) === "/api/connection/connect") { + return new Promise((resolve) => { + setTimeout(() => resolve(new Response(JSON.stringify("conn-1"), { status: 200, headers: { "Content-Type": "application/json" } })), 3500); + }); + } + if (String(input) === "/api/connection/disconnect") { + const body = JSON.parse(String(init?.body ?? "{}")) as { connectionId?: string }; + if (body.connectionId) disconnected.push(body.connectionId); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + const config = { ...conn("conn-1"), connect_timeout_secs: 1 }; + const connectPromise = store.connect(config); + const timeoutRejection = assert.rejects(() => connectPromise, /Connection attempt timed out after 3s/); + + await vi.advanceTimersByTimeAsync(3000); + await timeoutRejection; + assert.equal(store.connectedIds.has("conn-1"), false); + + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + assert.deepEqual(disconnected, ["conn-1"]); + assert.equal(store.connectedIds.has("conn-1"), false); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("late original connect success does not disconnect a newer connected state", async () => { + vi.useFakeTimers(); + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + const disconnected: string[] = []; + globalThis.fetch = (async (input, init) => { + if (String(input) === "/api/connection/connect") { + return new Promise((resolve) => { + setTimeout(() => resolve(new Response(JSON.stringify("conn-1"), { status: 200, headers: { "Content-Type": "application/json" } })), 3500); + }); + } + if (String(input) === "/api/connection/disconnect") { + const body = JSON.parse(String(init?.body ?? "{}")) as { connectionId?: string }; + if (body.connectionId) disconnected.push(body.connectionId); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + const config = { ...conn("conn-1"), connect_timeout_secs: 1 }; + const connectPromise = store.connect(config); + const timeoutRejection = assert.rejects(() => connectPromise, /Connection attempt timed out after 3s/); + + await vi.advanceTimersByTimeAsync(3000); + await timeoutRejection; + store.connectedIds.add("conn-1"); + + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + assert.deepEqual(disconnected, []); + assert.equal(store.connectedIds.has("conn-1"), true); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("hanging database metadata load times out and clears loading state", async () => { + vi.useFakeTimers(); + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (String(input).startsWith("/api/schema/databases?")) { + return new Promise(() => {}); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + store.activeConnectionId = "conn-1"; + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + children: [], + }); + + const loadPromise = store.loadDatabases("conn-1"); + const timeoutRejection = assert.rejects(() => loadPromise, /Connection timed out while loading databases after 35s/); + + await vi.advanceTimersByTimeAsync(35000); + await timeoutRejection; + + assert.equal(store.treeNodes[0].isLoading, false); + assert.equal(store.connectedIds.has("conn-1"), false); + assert.equal(store.activeConnectionId, null); + assert.match(store.connectionErrors["conn-1"], /Connection timed out while loading databases/); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test.each([ + ["redis", "loadRedisDatabases", "/api/redis/list-databases", "Redis databases"], + ["mq", "loadMqTenants", "/api/mq/tenants/list", "message queue tenants"], + ["mongodb", "loadMongoDatabases", "/api/mongo/list-databases", "MongoDB databases"], + ["elasticsearch", "loadElasticsearchIndices", "/api/mongo/list-collections", "Elasticsearch indices"], + ["qdrant", "loadVectorCollections", "/api/mongo/list-collections", "vector collections"], +] as const)("hanging %s root metadata load times out and clears loading state", async (dbType, loader, endpoint, label) => { + vi.useFakeTimers(); + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (String(input) === endpoint) { + return new Promise(() => {}); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection({ ...conn("conn-1"), db_type: dbType }); + store.activeConnectionId = "conn-1"; + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + children: [], + }); + + const loadPromise = store[loader]("conn-1"); + const timeoutRejection = assert.rejects(() => loadPromise, new RegExp(`Connection timed out while loading ${label} after 35s`)); + + await vi.advanceTimersByTimeAsync(35000); + await timeoutRejection; + + assert.equal(store.treeNodes[0].isLoading, false); + assert.equal(store.connectedIds.has("conn-1"), false); + assert.equal(store.activeConnectionId, null); + assert.match(store.connectionErrors["conn-1"], new RegExp(`Connection timed out while loading ${label}`)); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); diff --git a/packages/app-tests/sidebarSearchTree.test.ts b/packages/app-tests/sidebarSearchTree.test.ts index 2b909fe18..97d451820 100644 --- a/packages/app-tests/sidebarSearchTree.test.ts +++ b/packages/app-tests/sidebarSearchTree.test.ts @@ -145,3 +145,29 @@ test("connection search results stay visible before connecting", () => { ["conn:1"], ); }); + +test("connection search copies do not keep stale loading state", () => { + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "Orders local", + type: "connection", + connectionId: "conn:1", + isLoading: true, + children: [ + { + id: "conn:1:db", + label: "orders", + type: "database", + connectionId: "conn:1", + database: "orders", + }, + ], + }, + ]; + + const filtered = filterSidebarTree(nodes, "orders", new Set()); + + assert.equal(filtered[0]?.type, "connection"); + assert.equal(filtered[0]?.isLoading, false); +}); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 5ab7144d0..9bcd9ebfc 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -512,7 +512,7 @@ async fn drop_mq_adapters_for_connection_ids(_state: &AppState, _connection_ids: async fn remove_connection_pools_for_connection_ids(state: &AppState, connection_ids: &[String]) { for connection_id in connection_ids { - state.remove_connection_pools(connection_id).await; + state.remove_connection_pools_detached(connection_id).await; } } @@ -832,10 +832,11 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi let config = config.canonicalized(); let id = config.id.clone(); let db_config = metadata_connection_config(&config); + let attempt = state.begin_connection_attempt(&id).await; let mut connected_config = config.clone(); let mut connected_db_config = db_config.clone(); - state.remove_connection_pools(&id).await; + state.remove_connection_pools_detached(&id).await; state.reset_connection_transport_for_config(&id, &db_config).await; let (host, port) = state.connection_host_port(&id, &db_config).await?; @@ -922,8 +923,16 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi .await { Ok(()) => { + state + .insert_connection_pool_for_attempt( + &id, + attempt, + id.clone(), + PoolKind::MongoDb(client), + &db_config, + ) + .await?; state.configs.write().await.insert(id.clone(), config); - state.insert_connection_pool(id.clone(), PoolKind::MongoDb(client), &db_config).await; return Ok(id); } Err(e) => e, @@ -1085,7 +1094,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi db_type => return Err(format!("Unsupported database type: {db_type:?}")), }; - state.insert_connection_pool(id.clone(), pool, &connected_db_config).await; + state.insert_connection_pool_for_attempt(&id, attempt, id.clone(), pool, &connected_db_config).await?; state.configs.write().await.insert(id.clone(), connected_config); Ok(id) @@ -1111,7 +1120,8 @@ pub async fn connection_final_proxy_port( #[tauri::command] pub async fn disconnect_db(state: State<'_, Arc>, connection_id: String) -> Result<(), String> { - state.remove_connection_pools(&connection_id).await; + state.supersede_connection_attempt(&connection_id).await; + state.remove_connection_pools_detached(&connection_id).await; drop_nacos_adapters_for_connection_ids(state.inner(), std::slice::from_ref(&connection_id)).await; drop_mq_adapters_for_connection_ids(state.inner(), std::slice::from_ref(&connection_id)).await; state.reset_connection_transport(&connection_id).await;