From e2f68de4556c55403790d513f286eb900e83dad4 Mon Sep 17 00:00:00 2001 From: fagao Date: Mon, 22 Jun 2026 20:40:40 +0800 Subject: [PATCH 1/4] fix: resolve MySQL connection state desync after disconnect/reconnect - Use synchronous pool removal in desktop disconnect_db to prevent race condition when user quickly disconnects and reconnects - Rename recordMetadataLoadError to recordConnectionLostError and export it for cross-store reuse - Sync query execution errors back to connection state via recordConnectionLostError in queryStore catch blocks - Add backend check_connection_health method (dbx-core) with Tauri command and web route for proactive pool verification - Add frontend checkConnectionHealth API (tauri.ts/http.ts/api.ts) - Enhance ensureConnected to verify backend pool health before assuming connection is valid, auto-reconnect if stale --- apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/http.ts | 4 ++ apps/desktop/src/lib/tauri.ts | 4 ++ apps/desktop/src/stores/connectionStore.ts | 55 +++++++++++++--------- apps/desktop/src/stores/queryStore.ts | 9 +++- crates/dbx-core/src/connection.rs | 26 ++++++++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/connection.rs | 8 ++++ src-tauri/src/commands/connection.rs | 7 ++- src-tauri/src/lib.rs | 1 + 10 files changed, 92 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index cc02bf16c..63a590aaf 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -53,6 +53,7 @@ export const testConnection = forward("testConnection"); export const connectDb = forward("connectDb"); export const connectionFinalProxyPort = forward("connectionFinalProxyPort"); export const disconnectDb = forward("disconnectDb"); +export const checkConnectionHealth = forward("checkConnectionHealth"); export const closeDatabaseConnection = forward("closeDatabaseConnection"); export const refreshConnections = forward("refreshConnections"); export const saveConnections = forward("saveConnections"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index bc1e0f2ed..df1811f03 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -164,6 +164,10 @@ export async function disconnectDb(connectionId: string): Promise { return post("/api/connection/disconnect", { connectionId }); } +export async function checkConnectionHealth(connectionId: string): Promise { + return post("/api/connection/check-health", { connectionId }); +} + export async function closeDatabaseConnection(connectionId: string, database: string): Promise { return post("/api/connection/close-database", { connectionId, database }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 199ebe0f5..3b366196f 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -473,6 +473,10 @@ export async function disconnectDb(connectionId: string): Promise { return invoke("disconnect_db", { connectionId }); } +export async function checkConnectionHealth(connectionId: string): Promise { + return invoke("check_connection_health", { connectionId }); +} + export async function closeDatabaseConnection(connectionId: string, database: string): Promise { return invoke("close_database_connection", { connectionId, database }); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 64baf1d89..279968c50 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -260,7 +260,7 @@ export const useConnectionStore = defineStore("connection", () => { return message; } - function recordMetadataLoadError(connectionId: string, error: unknown) { + function recordConnectionLostError(connectionId: string, error: unknown) { if (shouldMarkDisconnected(error)) { connectedIds.value.delete(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; @@ -902,7 +902,17 @@ export const useConnectionStore = defineStore("connection", () => { } async function ensureConnected(connectionId: string) { - if (connectedIds.value.has(connectionId)) return; + if (connectedIds.value.has(connectionId)) { + // Optimistic: verify backend pool is actually healthy + try { + await api.checkConnectionHealth(connectionId); + return; + } catch { + // Backend pool is dead — remove from connectedIds and reconnect + connectedIds.value.delete(connectionId); + if (activeConnectionId.value === connectionId) activeConnectionId.value = null; + } + } let config = getConfig(connectionId); if (!config) { await initFromDisk(); @@ -1020,7 +1030,7 @@ export const useConnectionStore = defineStore("connection", () => { } node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1063,7 +1073,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1097,7 +1107,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1127,7 +1137,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1191,7 +1201,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1223,7 +1233,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1255,7 +1265,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1283,7 +1293,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1322,7 +1332,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1358,7 +1368,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1391,7 +1401,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1426,7 +1436,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1468,7 +1478,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(node.connectionId, e); + recordConnectionLostError(node.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1524,7 +1534,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1584,7 +1594,7 @@ export const useConnectionStore = defineStore("connection", () => { } node.isExpanded = true; } catch (e) { - recordMetadataLoadError(node.connectionId, e); + recordConnectionLostError(node.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1623,7 +1633,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(objectGroupCacheKey(parent), nextChildren); parent.isExpanded = true; } catch (e) { - recordMetadataLoadError(parent.connectionId, e); + recordConnectionLostError(parent.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1755,7 +1765,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1792,7 +1802,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1833,7 +1843,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1870,7 +1880,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordMetadataLoadError(connectionId, e); + recordConnectionLostError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -3039,6 +3049,7 @@ export const useConnectionStore = defineStore("connection", () => { setConnectionError, clearConnectionError, recordConnectionError, + recordConnectionLostError, sidebarLayout, getConfig, isTreeNodePinned, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8da38dcd2..6f2243bb8 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1638,6 +1638,8 @@ export const useQueryStore = defineStore("query", () => { } } catch (e: any) { console.error("[DBX][executeTabSql:error]", { traceId, elapsed: elapsed(), error: e }); + // Sync connection state if the error indicates a lost connection + useConnectionStore().recordConnectionLostError(tab.connectionId, e); const current = tabs.value.find((t) => t.id === id); if (current?.executionId === executionId) { current.result = toErrorResult(e); @@ -1787,6 +1789,8 @@ export const useQueryStore = defineStore("query", () => { } return canceled; } catch (e: any) { + // Sync connection state if the error indicates a lost connection + if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e); const current = tabs.value.find((t) => t.id === id); if (current && current.executionId === executionId) { current.isCancelling = false; @@ -1833,12 +1837,15 @@ export const useQueryStore = defineStore("query", () => { function notifyConnectionMayBeLost() { const stuck = tabs.value.filter((t) => t.isExecuting); if (stuck.length > 0) { + const connStore = useConnectionStore(); stuck.forEach((tab) => { tab.isExecuting = false; tab.isCancelling = false; tab.queryExecutionStartedAt = undefined; tab.executionId = undefined; - tab.result = toErrorResult(new Error(t("editor.connectionMayBeLost"))); + const error = new Error(t("editor.connectionMayBeLost")); + tab.result = toErrorResult(error); + connStore.recordConnectionLostError(tab.connectionId, error); }); } } diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 317ce8a77..b22b1f1fc 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1281,6 +1281,32 @@ impl AppState { self.proxy_tunnels.stop_tunnel(connection_id).await; } + /// Health-check the base connection pool for a given connection_id. + /// Returns `Ok(())` if the pool exists and is healthy, `Err` otherwise. + /// If the pool is unhealthy it is removed from the map so subsequent + /// `get_or_create_pool` calls will transparently recreate it. + pub async fn check_connection_health(&self, connection_id: &str) -> Result<(), String> { + let db_type = { + let configs = self.configs.read().await; + configs.get(connection_id).map(|c| c.db_type) + }; + let pool_key = base_pool_key_for(db_type, connection_id, None, false); + + // Check if pool exists first + { + let connections = self.connections.read().await; + if !connections.contains_key(&pool_key) { + return Err("No active connection pool found".to_string()); + } + } + + // `remove_stale_connection_pool` returns true if the pool was stale (and removed) + if self.remove_stale_connection_pool(&pool_key).await { + return Err("Connection pool is unhealthy".to_string()); + } + Ok(()) + } + pub async fn refresh_connections(&self) { // Clone pool handles under a short-lived read lock, then release it // before performing I/O-heavy health checks to avoid blocking writers. diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 794cb10fd..de9b3e11f 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -153,6 +153,7 @@ async fn main() { .route("/connection/connect", post(routes::connection::connect_db)) .route("/connection/final-proxy-port", post(routes::connection::connection_final_proxy_port)) .route("/connection/disconnect", post(routes::connection::disconnect_db)) + .route("/connection/check-health", post(routes::connection::check_connection_health)) .route("/connection/close-database", post(routes::connection::close_database_connection)) .route("/connection/save", post(routes::connection::save_connections)) .route("/connection/list", get(routes::connection::load_connections)) diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 34f0c6214..6f35f7d3c 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -110,6 +110,14 @@ pub async fn disconnect_db( Ok(Json(())) } +pub async fn check_connection_health( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.check_connection_health(&body.connection_id).await.map_err(AppError)?; + Ok(Json(())) +} + pub async fn close_database_connection( State(state): State>, Json(body): Json, diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 381ca6f48..35e5561f6 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1017,7 +1017,7 @@ 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_detached(&connection_id).await; + state.remove_connection_pools(&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; if connection_id.starts_with("__visible_draft_") { @@ -1043,6 +1043,11 @@ pub async fn refresh_connections(state: State<'_, Arc>) -> Result<(), Ok(()) } +#[tauri::command] +pub async fn check_connection_health(state: State<'_, Arc>, connection_id: String) -> Result<(), String> { + state.check_connection_health(&connection_id).await +} + /// Check whether a connection has read-only protection enabled. /// Returns an error if the connection is read-only, preventing write operations. pub async fn ensure_connection_writable( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0057b804..86e28c1cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -423,6 +423,7 @@ pub fn run() { commands::connection::disconnect_db, commands::connection::close_database_connection, commands::connection::refresh_connections, + commands::connection::check_connection_health, commands::connection::save_connections, commands::connection::load_connections, commands::connection::save_sidebar_layout, From c0248cbe8e5e63478cd542f4a4b719861cf8a49f Mon Sep 17 00:00:00 2001 From: fagao Date: Mon, 22 Jun 2026 21:43:01 +0800 Subject: [PATCH 2/4] fix: handle connection loss errors consistently --- apps/desktop/src/lib/connectionHealth.ts | 17 ++- apps/desktop/src/stores/connectionStore.ts | 59 ++++--- apps/desktop/src/stores/queryStore.ts | 2 +- .../connectionStoreErrorState.test.ts | 37 +++++ packages/app-tests/queryStore.test.ts | 144 ++++++++++++------ 5 files changed, 185 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/lib/connectionHealth.ts b/apps/desktop/src/lib/connectionHealth.ts index 91e1b4789..d43f70a19 100644 --- a/apps/desktop/src/lib/connectionHealth.ts +++ b/apps/desktop/src/lib/connectionHealth.ts @@ -1,4 +1,19 @@ -const CONNECTION_ERROR_PATTERNS = ["connection", "broken pipe", "reset by peer", "timed out", "closed", "eof", "i/o error"]; +const CONNECTION_ERROR_PATTERNS = [ + "connection reset", + "connection refused", + "connection timed out", + "connection closed", + "connection lost", + "connection not found", + "connection config not found", + "not connected", + "broken pipe", + "reset by peer", + "socket closed", + "unexpected eof", + "end-of-file on communication channel", + "i/o error", +]; export function staleConnectionMessage(error: unknown): string { if (error instanceof Error) return error.message; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 279968c50..72594b3b1 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -260,11 +260,23 @@ export const useConnectionStore = defineStore("connection", () => { return message; } - function recordConnectionLostError(connectionId: string, error: unknown) { + function markConnectionLost(connectionId: string, error: unknown) { + connectedIds.value.delete(connectionId); + if (activeConnectionId.value === connectionId) activeConnectionId.value = null; + recordConnectionError(connectionId, error); + } + + function recordConnectionLostError(connectionId: string, error: unknown): boolean { if (shouldMarkDisconnected(error)) { - connectedIds.value.delete(connectionId); - if (activeConnectionId.value === connectionId) activeConnectionId.value = null; + markConnectionLost(connectionId, error); + return true; } + return false; + } + + // Metadata loaders keep this internal: match connection-loss errors before recording generic errors. + function recordMetadataLoadError(connectionId: string, error: unknown) { + if (recordConnectionLostError(connectionId, error)) return; recordConnectionError(connectionId, error); } @@ -1030,7 +1042,7 @@ export const useConnectionStore = defineStore("connection", () => { } node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1073,7 +1085,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1107,7 +1119,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1137,7 +1149,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1201,7 +1213,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1233,7 +1245,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1265,7 +1277,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1293,7 +1305,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1332,7 +1344,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1368,7 +1380,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1401,7 +1413,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1436,7 +1448,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1478,7 +1490,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(node.connectionId, e); + recordMetadataLoadError(node.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1534,7 +1546,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1594,7 +1606,7 @@ export const useConnectionStore = defineStore("connection", () => { } node.isExpanded = true; } catch (e) { - recordConnectionLostError(node.connectionId, e); + recordMetadataLoadError(node.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1633,7 +1645,7 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(objectGroupCacheKey(parent), nextChildren); parent.isExpanded = true; } catch (e) { - recordConnectionLostError(parent.connectionId, e); + recordMetadataLoadError(parent.connectionId, e); throw e; } finally { node.isLoading = false; @@ -1765,7 +1777,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1802,7 +1814,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1843,7 +1855,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -1880,7 +1892,7 @@ export const useConnectionStore = defineStore("connection", () => { ); node.isExpanded = true; } catch (e) { - recordConnectionLostError(connectionId, e); + recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; @@ -3049,6 +3061,7 @@ export const useConnectionStore = defineStore("connection", () => { setConnectionError, clearConnectionError, recordConnectionError, + markConnectionLost, recordConnectionLostError, sidebarLayout, getConfig, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 6f2243bb8..6410c76a5 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1845,7 +1845,7 @@ export const useQueryStore = defineStore("query", () => { tab.executionId = undefined; const error = new Error(t("editor.connectionMayBeLost")); tab.result = toErrorResult(error); - connStore.recordConnectionLostError(tab.connectionId, error); + connStore.markConnectionLost(tab.connectionId, error); }); } } diff --git a/packages/app-tests/connectionStoreErrorState.test.ts b/packages/app-tests/connectionStoreErrorState.test.ts index a1e93d74d..5479298ad 100644 --- a/packages/app-tests/connectionStoreErrorState.test.ts +++ b/packages/app-tests/connectionStoreErrorState.test.ts @@ -83,3 +83,40 @@ test("failed disconnect keeps the existing connection error", async () => { restoreStorage(); } }); + +test("query errors mentioning connection do not mark the connection disconnected", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + store.activeConnectionId = "conn-1"; + + store.recordConnectionLostError("conn-1", new Error('relation "connection" does not exist')); + + assert.equal(store.connectedIds.has("conn-1"), true); + assert.equal(store.activeConnectionId, "conn-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + restoreStorage(); + } +}); + +test("explicit lost-connection marker clears state without relying on error text", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + store.activeConnectionId = "conn-1"; + + store.markConnectionLost("conn-1", new Error("连接可能已断开,请刷新数据重试")); + + assert.equal(store.connectedIds.has("conn-1"), false); + assert.equal(store.activeConnectionId, null); + assert.equal(store.connectionErrors["conn-1"], "连接可能已断开,请刷新数据重试"); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + restoreStorage(); + } +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 43f3ae7a5..cc91a0fcc 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -46,6 +46,15 @@ function oracleConn(id: string): ConnectionConfig { }; } +function withConnectionHealthMock(handler: typeof fetch): typeof fetch { + return (async (input, init) => { + if (String(input) === "/api/connection/check-health") { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return handler(input, init); + }); +} + async function waitFor(predicate: () => boolean, timeoutMs = 1000) { const started = Date.now(); while (!predicate()) { @@ -383,7 +392,7 @@ test("completed query executions append result runs and select the latest run", let executeCount = 0; connectionStore.addEphemeralConnection(conn("conn-1")); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { const body = JSON.parse(String(init?.body ?? "{}")); @@ -406,7 +415,7 @@ test("completed query executions append result runs and select the latest run", }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const tabId = store.createTab("conn-1", "db", "Query"); @@ -438,7 +447,7 @@ test("failed query executions append switchable error result runs", async () => const originalFetch = globalThis.fetch; connectionStore.addEphemeralConnection(conn("conn-1")); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { const body = JSON.parse(String(init?.body ?? "{}")); @@ -451,7 +460,7 @@ test("failed query executions append switchable error result runs", async () => return new Response("backend exploded", { status: 500 }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const tabId = store.createTab("conn-1", "db", "Query"); @@ -469,6 +478,43 @@ test("failed query executions append switchable error result runs", async () => } }); +test("query execution errors mentioning connection keep the connection active", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-1")); + connectionStore.activeConnectionId = "conn-1"; + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/query/execute-multi") { + return new Response('relation "connection" does not exist', { status: 500 }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("conn-1", "db", "Query"); + await store.executeTabSql(tabId, "select connection from missing_table"); + + assert.equal(connectionStore.connectedIds.has("conn-1"), true); + assert.equal(connectionStore.activeConnectionId, "conn-1"); + assert.equal(connectionStore.connectionErrors["conn-1"], undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("statement result switching is scoped to the active result run", async () => { setActivePinia(createPinia()); const store = useQueryStore(); @@ -524,7 +570,7 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad connectionStore.addEphemeralConnection(oracleConn("oracle-1")); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/execute-multi") { return new Response( @@ -596,7 +642,7 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const tabId = store.createTab("oracle-1", "ORCL", "Query 1", "query", "app"); @@ -626,7 +672,7 @@ test("evicting cached tab results releases multi-result payloads and sessions", connectionStore.addEphemeralConnection(conn("conn-1")); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/execute-multi") { executeCount++; @@ -675,7 +721,7 @@ test("evicting cached tab results releases multi-result payloads and sessions", ); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const tabIds: string[] = []; @@ -709,7 +755,7 @@ test("result cache eviction keeps recently accessed inactive tabs", async () => connectionStore.addEphemeralConnection(conn("conn-1")); - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/execute-multi") { executeCount++; @@ -743,7 +789,7 @@ test("result cache eviction keeps recently accessed inactive tabs", async () => }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const tabIds: string[] = []; @@ -775,9 +821,9 @@ test("result cache eviction keeps recently accessed inactive tabs", async () => test("closing tabs clears removed result payloads before dropping tab references", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); const store = useQueryStore(); @@ -813,9 +859,9 @@ test("closing tabs clears removed result payloads before dropping tab references test("closing database tabs removes browser tabs for that database only", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); @@ -863,9 +909,9 @@ test("closing database tabs removes browser tabs for that database only", async test("closing connection tabs removes every tab for that connection only", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); @@ -910,9 +956,9 @@ test("closing connection tabs removes every tab for that connection only", async test("releasing connection tabs keeps SQL tabs and closes object tabs", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); @@ -970,9 +1016,9 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async () test("releasing database tabs keeps SQL tabs and closes table tabs for that database only", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); @@ -1015,9 +1061,9 @@ test("releasing database tabs keeps SQL tabs and closes table tabs for that data test("disconnecting a connection closes every tab for that connection", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { + globalThis.fetch = withConnectionHealthMock(async () => { return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }); try { setActivePinia(createPinia()); @@ -1066,7 +1112,7 @@ test("starting a new query clears the previous result payload immediately", asyn execution_time_ms: 1, }; - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), { @@ -1087,7 +1133,7 @@ test("starting a new query clears the previous result payload immediately", asyn }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const execution = store.executeTabSql(tabId, "select 1"); @@ -1120,7 +1166,7 @@ test("grid refreshes can preserve the previous result while loading", async () = }; tab.result = previousResult; - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response(JSON.stringify({ sqlToExecute: "select 1 order by name", useAgentResultSession: false }), { @@ -1141,7 +1187,7 @@ test("grid refreshes can preserve the previous result while loading", async () = }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const execution = store.executeTabSql(tabId, "select 1 order by name", { @@ -1172,7 +1218,7 @@ test("data tab execution preserves pagination offset metadata", async () => { const tab = store.tabs.find((item) => item.id === tabId); assert.ok(tab); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { preparedPagination = true; @@ -1186,7 +1232,7 @@ test("data tab execution preserves pagination offset metadata", async () => { }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 100 OFFSET 100;', { @@ -1224,7 +1270,7 @@ test("activating an empty data tab waits for explicit execution", async () => { tab.resultPageLimit = 50; tab.resultPageOffset = 50; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/execute-multi") { executeBody = JSON.parse(String(init?.body ?? "{}")); @@ -1234,7 +1280,7 @@ test("activating an empty data tab waits for explicit execution", async () => { }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.reloadEvictedTab(tabId); @@ -1275,7 +1321,7 @@ test("query result export fetches every paginated page", async () => { has_more: true, }; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { const body = JSON.parse(String(init?.body ?? "{}")); @@ -1306,7 +1352,7 @@ test("query result export fetches every paginated page", async () => { }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const exported = await store.fetchTabResultForExport(tabId); @@ -1350,7 +1396,7 @@ test("query result export treats the known query total as a progress estimate", has_more: true, }; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { const body = JSON.parse(String(init?.body ?? "{}")); @@ -1378,7 +1424,7 @@ test("query result export treats the known query total as a progress estimate", }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const exported = await store.fetchTabResultForExport(tabId, (info) => progress.push(info)); @@ -1416,7 +1462,7 @@ test("jdbc query pagination uses result sessions without capping max rows to one const tab = store.tabs.find((item) => item.id === tabId); assert.ok(tab); - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { prepareBody = JSON.parse(String(init?.body ?? "{}")); @@ -1453,7 +1499,7 @@ test("jdbc query pagination uses result sessions without capping max rows to one }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "SELECT * FROM CT_Loc"); @@ -1518,7 +1564,7 @@ test("table data export fetches every filtered page", async () => { primaryKeys: ["id"], }; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/build-table-select-sql") { const body = JSON.parse(String(init?.body ?? "{}")); @@ -1541,7 +1587,7 @@ test("table data export fetches every filtered page", async () => { }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { const exported = await store.fetchTabResultForExport(tabId); @@ -1599,7 +1645,7 @@ test("query execution finishes without waiting for metadata analysis", async () assert.ok(tab); let resolveMetadata: ((value: Response) => void) | undefined; - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response(JSON.stringify({ sqlToExecute: "select id from users", useAgentResultSession: false }), { @@ -1619,7 +1665,7 @@ test("query execution finishes without waiting for metadata analysis", async () }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "select id from users"); @@ -1652,7 +1698,7 @@ test("query execution is scoped to the tab client session", async () => { const tabId = store.createTab("conn-1", "db", "Query"); let executeBody: any; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), { @@ -1674,7 +1720,7 @@ test("query execution is scoped to the tab client session", async () => { }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "select 1"); @@ -1701,7 +1747,7 @@ test("query execution keeps automatically counting total rows in the background" let resolveCount: ((value: Response) => void) | undefined; let countBody: any; - globalThis.fetch = (async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response( @@ -1742,7 +1788,7 @@ test("query execution keeps automatically counting total rows in the background" }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "select id from users"); @@ -1786,7 +1832,7 @@ test("paginated query execution keeps the previous total while refreshing it in tab.resultTotalRowCount = 250; let resolveCount: ((value: Response) => void) | undefined; - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response( @@ -1826,7 +1872,7 @@ test("paginated query execution keeps the previous total while refreshing it in }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "select id from users", { @@ -1867,7 +1913,7 @@ test("multi statement execution shows the first result set by default", async () connectionStore.addEphemeralConnection(conn("conn-1")); const tabId = store.createTab("conn-1", "db", "Query"); - globalThis.fetch = (async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { const url = String(input); if (url === "/api/query/prepare-pagination-plan") { return new Response(JSON.stringify({ sqlToExecute: "set @id = 1; select @id", useAgentResultSession: false }), { @@ -1891,7 +1937,7 @@ test("multi statement execution shows the first result set by default", async () }); } return new Response("unexpected request", { status: 500 }); - }) as typeof fetch; + }); try { await store.executeTabSql(tabId, "set @id = 1; select @id"); @@ -2005,7 +2051,7 @@ test("reorderTab preserves relative order within pinned group", () => { const tabD = store.createTab("conn-1", "db", "D", "query"); const tabE = store.createTab("conn-1", "db", "E", "query"); - // Pin A, B, C — leave D, E unpinned + // Pin A, B, C; leave D, E unpinned store.togglePinnedTab(tabA); // toggle so orderPinnedFirst runs: [A, B, C, D, E] store.togglePinnedTab(tabB); From 718ffde871841635525b6550ce5cde194c65ac7a Mon Sep 17 00:00:00 2001 From: fagao Date: Mon, 22 Jun 2026 22:02:49 +0800 Subject: [PATCH 3/4] test: mock MQ connection health check --- apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts index 22d5853e2..e3f32d23d 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.mq.spec.ts @@ -49,6 +49,7 @@ describe("connectionStore MQ sidebar tree", () => { vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), listDatabases: vi.fn().mockResolvedValue([]), loadSchemaCache: vi.fn().mockResolvedValue(null), From 099ed62609e5fa0ffe957db076a5fc55f080a682 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 22 Jun 2026 22:23:28 +0800 Subject: [PATCH 4/4] fix: preserve known connection loss errors --- apps/desktop/src/lib/connectionHealth.ts | 11 +++++++ .../connectionStoreErrorState.test.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/desktop/src/lib/connectionHealth.ts b/apps/desktop/src/lib/connectionHealth.ts index d43f70a19..57527c7c7 100644 --- a/apps/desktop/src/lib/connectionHealth.ts +++ b/apps/desktop/src/lib/connectionHealth.ts @@ -7,11 +7,22 @@ const CONNECTION_ERROR_PATTERNS = [ "connection not found", "connection config not found", "not connected", + "closed the connection", "broken pipe", "reset by peer", "socket closed", "unexpected eof", + "end-of-file", "end-of-file on communication channel", + "server closed session", + "communicating with the server", + "exceeded maximum idle time", + "agent stdin not available", + "agent stdout not available", + "failed to write to agent stdin", + "failed to flush agent stdin", + "关闭的连接", + "连接已关闭", "i/o error", ]; diff --git a/packages/app-tests/connectionStoreErrorState.test.ts b/packages/app-tests/connectionStoreErrorState.test.ts index 5479298ad..09ad8e5b2 100644 --- a/packages/app-tests/connectionStoreErrorState.test.ts +++ b/packages/app-tests/connectionStoreErrorState.test.ts @@ -102,6 +102,39 @@ test("query errors mentioning connection do not mark the connection disconnected } }); +test("known backend connection errors mark the connection disconnected", async () => { + const restoreStorage = installMemoryStorage(); + const messages = [ + "java.sql.SQLRecoverableException: 关闭的连接", + "java.sql.SQLRecoverableException: 连接已关闭", + "server closed session with no notification", + "server closed the connection unexpectedly", + "Error occurred while creating a new object: error communicating with the server", + "ORA-02396: exceeded maximum idle time, please connect again", + "Agent stdin not available", + "Failed to write to agent stdin", + ]; + + try { + for (const [index, message] of messages.entries()) { + setActivePinia(createPinia()); + const store = useConnectionStore(); + const connectionId = `conn-${index}`; + store.addEphemeralConnection(conn(connectionId)); + store.activeConnectionId = connectionId; + + const marked = store.recordConnectionLostError(connectionId, new Error(message)); + + assert.equal(marked, true, message); + assert.equal(store.connectedIds.has(connectionId), false, message); + assert.equal(store.activeConnectionId, null, message); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + restoreStorage(); + } +}); + test("explicit lost-connection marker clears state without relying on error text", async () => { const restoreStorage = installMemoryStorage(); try {