diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 4a8bddab79..a42e8a8fb9 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -122,6 +122,7 @@ const defaultForm = (): ConnectionForm => ({ transport_layers: [], connect_timeout_secs: 5, query_timeout_secs: 30, + idle_timeout_secs: 60, ssl: false, ca_cert_path: "", client_cert_path: "", @@ -530,6 +531,7 @@ watch( transport_layers: transportLayersForConfig(legacyConfig), connect_timeout_secs: config.connect_timeout_secs || 5, query_timeout_secs: config.query_timeout_secs ?? 30, + idle_timeout_secs: config.idle_timeout_secs ?? 60, ssl: config.ssl || false, ca_cert_path: config.ca_cert_path || "", client_cert_path: config.client_cert_path || "", @@ -1039,6 +1041,8 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 5; const queryTimeout = Number(config.query_timeout_secs); config.query_timeout_secs = Number.isFinite(queryTimeout) && queryTimeout >= 0 ? queryTimeout : 30; + const idleTimeout = Number(config.idle_timeout_secs); + config.idle_timeout_secs = Number.isFinite(idleTimeout) && idleTimeout >= 0 ? idleTimeout : 60; if (!config.one_time) config.one_time = undefined; if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; @@ -3053,6 +3057,17 @@ function openExternalUrl(url: string) { class="col-span-3" /> +
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b9c3a22a60..c053ae3a95 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -273,6 +273,7 @@ export default { sshHopInvalidTimeout: "{hop}: SSH timeout must be between 1 and 300 seconds", connectTimeout: "Connection Timeout (seconds)", queryTimeout: "Query Timeout (seconds)", + idleTimeout: "Idle Timeout (seconds)", proxy: "Proxy", proxyEnable: "Connect database through proxy", proxyType: "Proxy Type", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d89fc92d6c..bc1dc3b868 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -254,6 +254,7 @@ export default { sshHopInvalidTimeout: "{hop}: el tiempo de espera SSH debe estar entre 1 y 300 segundos", connectTimeout: "Tiempo de espera de conexión (segundos)", queryTimeout: "Tiempo de espera de consulta (segundos)", + idleTimeout: "Tiempo de espera inactivo (segundos)", proxy: "Proxy", proxyEnable: "Conectar la base de datos mediante proxy", proxyType: "Tipo de proxy", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0bb0c5d4c2..88165a7516 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -265,6 +265,7 @@ export default { sshHopInvalidTimeout: "{hop}: Il timeout SSH deve essere compreso tra 1 e 300 secondi", connectTimeout: "Timeout Connessione (secondi)", queryTimeout: "Timeout Query (secondi)", + idleTimeout: "Timeout Inattività (secondi)", proxy: "Proxy", proxyEnable: "Connetti database tramite proxy", proxyType: "Tipo Proxy", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index dd2e398e37..ef957ae8f8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -264,6 +264,7 @@ export default { sshHopInvalidTimeout: "{hop}: o timeout SSH deve estar entre 1 e 300 segundos", connectTimeout: "Timeout de Conexão (segundos)", queryTimeout: "Timeout de Consulta (segundos)", + idleTimeout: "Timeout de Inatividade (segundos)", proxy: "Proxy", proxyEnable: "Conectar ao banco de dados via proxy", proxyType: "Tipo de Proxy", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4305cddf55..20a3fc76fb 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -269,6 +269,7 @@ export default { sshHopInvalidTimeout: "{hop}:SSH 超时时间必须在 1 到 300 秒之间", connectTimeout: "连接超时(秒)", queryTimeout: "查询超时(秒)", + idleTimeout: "空闲超时(秒)", proxy: "代理", proxyEnable: "通过代理连接数据库", proxyType: "代理类型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 87e95a4515..05e49e4ad9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -258,6 +258,7 @@ export default { sshHopInvalidTimeout: "{hop}:SSH 逾時時間必須在 1 到 300 秒之間", connectTimeout: "連線逾時(秒)", queryTimeout: "查詢逾時(秒)", + idleTimeout: "閒置逾時(秒)", proxy: "代理伺服器", proxyEnable: "藉由代理伺服器連線資料庫", proxyType: "代理伺服器類型", diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 753d947994..b9411081f3 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -305,6 +305,7 @@ export const useConnectionStore = defineStore("connection", () => { transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [], connect_timeout_secs: config.connect_timeout_secs || 5, query_timeout_secs: config.query_timeout_secs ?? 30, + idle_timeout_secs: config.idle_timeout_secs ?? 60, }; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index d8bfd712e1..5656c1a730 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -75,6 +75,7 @@ export interface ConnectionConfig { transport_layers?: TransportLayerConfig[]; connect_timeout_secs?: number; query_timeout_secs?: number; + idle_timeout_secs?: number; ssl?: boolean; ca_cert_path?: string; client_cert_path?: string; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index b105c0c192..10695ab7c2 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -324,6 +324,7 @@ impl AppState { probe_connection_endpoint(&db_config, &host, port).await?; let url = connection_url_for_endpoint(&db_config, &host, port); let connect_timeout = std::time::Duration::from_secs(db_config.effective_connect_timeout_secs()); + let idle_timeout = std::time::Duration::from_secs(db_config.idle_timeout_secs); let mysql_pool_max_connections = if normalize_client_session_id(client_session_id).is_some() { 1 } else { 3 }; let pool = match db_config.db_type { DatabaseType::Mysql => { @@ -401,7 +402,7 @@ impl AppState { PoolKind::DuckDb(con) } DatabaseType::MongoDb => { - let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { + let native_err = match db::mongo_driver::connect(&url, connect_timeout, idle_timeout).await { Ok(client) => match db::mongo_driver::test_connection( &client, connect_timeout, @@ -410,7 +411,12 @@ impl AppState { .await { Ok(()) => { - self.connections.write().await.insert(pool_key.clone(), PoolKind::MongoDb(client)); + let mut conns = self.connections.write().await; + // Re-check: another task may have created the pool while we were connecting. + if conns.contains_key(&pool_key) { + return Ok(pool_key); + } + conns.insert(pool_key.clone(), PoolKind::MongoDb(client)); return Ok(pool_key); } Err(e) => e, diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index a0641f060f..5f6ecf633f 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -16,7 +16,7 @@ pub struct MongoDocumentResult { pub total: u64, } -pub async fn connect(url: &str, timeout: Duration) -> Result { +pub async fn connect(url: &str, timeout: Duration, idle_timeout: Duration) -> Result { let is_multi_host = is_multi_host_mongo_uri(url); let parse_timeout = if is_multi_host { std::cmp::max(timeout * 2, Duration::from_secs(10)) } else { timeout }; @@ -25,6 +25,12 @@ pub async fn connect(url: &str, timeout: Duration) -> Result { options.connect_timeout = Some(timeout); options.server_selection_timeout = if is_multi_host { Some(std::cmp::max(timeout * 2, Duration::from_secs(10))) } else { Some(timeout) }; + // Close idle connections before the server-side timeout drops them, + // preventing "Broken pipe" (os error 32) or "unexpected end of file". + // 0 means no idle timeout (keep connections alive indefinitely). + if idle_timeout.as_secs() > 0 { + options.max_idle_time = Some(idle_timeout); + } Client::with_options(options).map_err(|e| format!("MongoDB connection failed: {e}")) }) .await diff --git a/crates/dbx-core/src/db/ssh_tunnel.rs b/crates/dbx-core/src/db/ssh_tunnel.rs index 30746f0e04..e937faf463 100644 --- a/crates/dbx-core/src/db/ssh_tunnel.rs +++ b/crates/dbx-core/src/db/ssh_tunnel.rs @@ -309,9 +309,15 @@ impl TunnelManager { remote_port: u16, expose_to_lan: bool, ) -> Result { - if let Some(local_port) = self.local_port(connection_id).await { - return Ok(local_port); + // Check cache under lock to avoid race with concurrent callers. + // Also evict stale entries whose background task has exited. + { + let mut tunnels = self.tunnels.lock().await; + if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) { + return Ok(port); + } } + // Slow SSH connection — do this outside the lock. let (handle, local_port) = spawn_tunnel( ssh_host, ssh_port, @@ -326,11 +332,28 @@ impl TunnelManager { ) .await?; - self.tunnels.lock().await.insert(connection_id.to_string(), TunnelEntry { handles: vec![handle], local_port }); - + // Re-check under lock: another caller may have beaten us. + let mut tunnels = self.tunnels.lock().await; + if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) { + // Another task already created a live tunnel; abort ours. + handle.abort(); + return Ok(port); + } + tunnels.insert(connection_id.to_string(), TunnelEntry { handles: vec![handle], local_port }); Ok(local_port) } + /// Returns the local port for a cached tunnel entry, or `None` if the entry + /// is stale (all background handles have exited). + fn get_active_port(tunnels: &mut HashMap, connection_id: &str) -> Option { + let entry = tunnels.get(connection_id)?; + if entry.handles.iter().all(|h| h.is_finished()) { + tunnels.remove(connection_id); + return None; + } + Some(entry.local_port) + } + pub async fn start_chain( &self, connection_id: &str, @@ -341,8 +364,12 @@ impl TunnelManager { if hops.is_empty() { return Err("No SSH tunnel hops configured".to_string()); } - if let Some(local_port) = self.local_port(connection_id).await { - return Ok(local_port); + // Check cache under lock; evict stale entries. + { + let mut tunnels = self.tunnels.lock().await; + if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) { + return Ok(port); + } } let mut handles = Vec::new(); @@ -379,11 +406,15 @@ impl TunnelManager { next_connect_endpoint = Some(("127.0.0.1".to_string(), local_port)); } - self.tunnels - .lock() - .await - .insert(connection_id.to_string(), TunnelEntry { handles, local_port: final_local_port }); - + // Re-check under lock: another caller may have beaten us. + let mut tunnels = self.tunnels.lock().await; + if let Some(port) = Self::get_active_port(&mut tunnels, connection_id) { + for handle in handles { + handle.abort(); + } + return Ok(port); + } + tunnels.insert(connection_id.to_string(), TunnelEntry { handles, local_port: final_local_port }); Ok(final_local_port) } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 717c6bb58e..d6e4e1971a 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -30,6 +30,8 @@ pub struct ConnectionConfig { pub connect_timeout_secs: u64, #[serde(default = "default_query_timeout_secs")] pub query_timeout_secs: u64, + #[serde(default = "default_idle_timeout_secs")] + pub idle_timeout_secs: u64, #[serde(default)] pub ssl: bool, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -180,6 +182,10 @@ pub fn default_query_timeout_secs() -> u64 { 30 } +pub fn default_idle_timeout_secs() -> u64 { + 60 +} + fn default_proxy_port() -> u16 { 1080 } @@ -295,6 +301,8 @@ struct ConnectionConfigData { pub connect_timeout_secs: u64, #[serde(default = "default_query_timeout_secs")] pub query_timeout_secs: u64, + #[serde(default = "default_idle_timeout_secs")] + pub idle_timeout_secs: u64, #[serde(default)] pub ssl: bool, #[serde(default)] @@ -355,6 +363,7 @@ impl From for ConnectionConfig { transport_layers: data.transport_layers, connect_timeout_secs: data.connect_timeout_secs, query_timeout_secs: data.query_timeout_secs, + idle_timeout_secs: data.idle_timeout_secs, ssl: data.ssl, ca_cert_path: data.ca_cert_path, client_cert_path: data.client_cert_path, diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 0d217bf936..05897bc0f2 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -15,6 +15,11 @@ pub const QUERY_TIMEOUT: Duration = Duration::from_secs(30); pub const MAX_ROWS: usize = 10000; pub const QUERY_CANCELED: &str = "Query canceled"; +async fn connection_is_mongodb(state: &AppState, connection_id: &str) -> bool { + let configs = state.configs.read().await; + configs.get(connection_id).is_some_and(|config| config.db_type == DatabaseType::MongoDb) +} + async fn connection_database_type(state: &AppState, connection_id: &str) -> Option { let configs = state.configs.read().await; configs.get(connection_id).map(|config| config.db_type) @@ -804,6 +809,14 @@ pub async fn execute_sql_statement_with_options( cancel_token: Option, options: QueryExecutionOptions, ) -> Result { + // MongoDB connections use shell-style commands dispatched through the + // frontend parser. Queries that fall through to the generic SQL executor + // (e.g. typos) must be rejected before any pool/key creation so that + // session-scoped pools do not leak MongoDB Clients and SSH tunnels. + if connection_is_mongodb(state, connection_id).await { + return Err("Use MongoDB-specific commands".to_string()); + } + // When a query tab has a client session, keep even database-less execution // on that tab-scoped pool so connection-level state (for example MySQL @vars) // survives across runs. @@ -893,6 +906,11 @@ pub async fn execute_multi_core_with_options( cancel_token: Option, options: QueryExecutionOptions, ) -> Result, String> { + // Reject MongoDB queries that fall through to the generic executor. + if connection_is_mongodb(state, connection_id).await { + return Err("Use MongoDB-specific commands".to_string()); + } + let pool_key = if database.is_empty() { state.get_or_create_pool_for_session(connection_id, None, options.client_session_id.as_deref()).await? } else { diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index f2994d5808..b88e24de14 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -247,6 +247,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection let url = connection_url_for_endpoint(&config, &host, port); let target = redacted_connection_url_for_endpoint(&config, &host, port); let connect_timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs()); + let idle_timeout = std::time::Duration::from_secs(config.idle_timeout_secs); log::info!("[test_connection] db_type={:?} target={}", config.db_type, target); let result = match probe_result { Err(e) => Err(e), @@ -324,7 +325,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection } } DatabaseType::MongoDb => { - let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { + let native_err = match db::mongo_driver::connect(&url, connect_timeout, idle_timeout).await { Ok(client) => { match db::mongo_driver::test_connection(&client, connect_timeout, config.effective_database()) .await @@ -434,6 +435,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi probe_connection_endpoint(&db_config, &host, port).await?; let url = connection_url_for_endpoint(&db_config, &host, port); let connect_timeout = std::time::Duration::from_secs(db_config.effective_connect_timeout_secs()); + let idle_timeout = std::time::Duration::from_secs(db_config.idle_timeout_secs); let pool = match db_config.db_type { DatabaseType::Mysql => { @@ -489,7 +491,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi PoolKind::DuckDb(con) } DatabaseType::MongoDb => { - let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { + let native_err = match db::mongo_driver::connect(&url, connect_timeout, idle_timeout).await { Ok(client) => { match db::mongo_driver::test_connection(&client, connect_timeout, db_config.effective_database()) .await