fix: 修复应用长时间挂后台后恢复时表格数据永久加载的问题

三层防护:
- MySQL 连接获取时执行 ping 健康检查,死连接自动重连
- macOS Reopen 事件 + 前端 visibilitychange 触发连接刷新
- 前端 Promise.race 超时保护,确保 loading 状态不会超过 60s
This commit is contained in:
t8y2 2026-05-29 15:12:14 +08:00
parent 6c0db6f517
commit b54eda1889
8 changed files with 157 additions and 13 deletions

View File

@ -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");

View File

@ -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);
});
}

View File

@ -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<never>((_, 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,
};
});

View File

@ -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<String> = {
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<String> = {
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<String> = conns

View File

@ -798,13 +798,26 @@ fn query_result_row_limit(max_rows: Option<usize>) -> 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<mysql_async::Conn, String> {
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<QueryResult, String> {
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<String> = 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<QueryResult, String> {
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<String> = 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,

View File

@ -355,7 +355,7 @@ pub async fn connect(url: &str, fallback_timeout: Duration) -> Result<Pool, Stri
let pg_config = tokio_postgres::Config::from_str(&postgres_url.url)
.map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast };
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Verified };
let tls_config = postgres_tls_config(
&pg_config,
&postgres_url.ssl_files,

View File

@ -1071,7 +1071,7 @@ async fn exec_tx_mysql_inner(
statements: &[String],
start: std::time::Instant,
) -> Result<db::QueryResult, String> {
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() {

View File

@ -511,3 +511,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
state.reset_connection_transport(&connection_id).await;
Ok(())
}
#[tauri::command]
pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(), String> {
state.refresh_connections().await;
Ok(())
}