diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 169dd3f7f..5939fffed 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1428,7 +1428,8 @@ async function persistSuccessfulConnectionTest(result: ConnectionTestResult, con } async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise { - const timeoutMs = connectionAttemptTimeoutMs(config); + await tunnelProfileStore.init(); + const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById); const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); const promise = api.testConnectionWithInfo(config); let timedOut = false; diff --git a/apps/desktop/src/lib/__tests__/connection/connectionAttemptTimeout.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionAttemptTimeout.spec.ts index a447b52b3..4d1ae779f 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionAttemptTimeout.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionAttemptTimeout.spec.ts @@ -51,6 +51,70 @@ describe("connectionAttemptTimeout", () => { ).toBe(27_000); }); + it("uses resolved shared SSH profile settings instead of reference stub defaults", () => { + const profile = { + type: "ssh" as const, + id: "shared-ssh", + name: "Slow bastion", + host: "bastion.example.com", + port: 22, + user: "dbx", + connect_timeout_secs: 40, + }; + + expect( + connectionAttemptTimeoutMs( + { + db_type: "redis", + connect_timeout_secs: 5, + transport_layers: [ + { + type: "ssh", + id: "connection-hop", + profile_id: profile.id, + host: "", + port: 22, + user: "root", + connect_timeout_secs: 5, + }, + ], + }, + (profileId) => (profileId === profile.id ? profile : undefined), + ), + ).toBe(42_000); + }); + + it("keeps disabled shared layers outside the attempt deadline", () => { + expect( + connectionAttemptTimeoutMs( + { + db_type: "redis", + connect_timeout_secs: 5, + transport_layers: [ + { + type: "ssh", + id: "connection-hop", + profile_id: "shared-ssh", + enabled: false, + host: "", + port: 22, + user: "root", + connect_timeout_secs: 5, + }, + ], + }, + () => ({ + type: "ssh", + id: "shared-ssh", + host: "bastion.example.com", + port: 22, + user: "dbx", + connect_timeout_secs: 40, + }), + ), + ).toBe(7_000); + }); + it("ignores disabled transport layer timeouts", () => { expect( connectionAttemptTimeoutMs({ diff --git a/apps/desktop/src/lib/connection/connectionAttemptTimeout.ts b/apps/desktop/src/lib/connection/connectionAttemptTimeout.ts index 0b6f6709f..3fae40d95 100644 --- a/apps/desktop/src/lib/connection/connectionAttemptTimeout.ts +++ b/apps/desktop/src/lib/connection/connectionAttemptTimeout.ts @@ -1,4 +1,4 @@ -import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, TransportLayerConfig, TunnelProfile } from "@/types/database"; export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000; export const MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS = 30_000; @@ -50,11 +50,23 @@ function positiveSeconds(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } -export function connectionAttemptTimeoutMs(config: Pick & Partial>): number { +export type TunnelProfileResolver = (profileId: string) => TunnelProfile | undefined; + +function resolvedTimeoutLayer(layer: TransportLayerConfig, resolveTunnelProfile?: TunnelProfileResolver): TransportLayerConfig { + if (!layer.profile_id || !resolveTunnelProfile) return layer; + const profile = resolveTunnelProfile(layer.profile_id); + // The backend rejects missing or mismatched profiles; retain the stub here so + // the UI deadline never masks that lifecycle error with invented settings. + if (!profile || profile.type !== layer.type) return layer; + return { ...profile, id: layer.id, enabled: layer.enabled, profile_id: layer.profile_id } as TransportLayerConfig; +} + +export function connectionAttemptTimeoutMs(config: Pick & Partial>, resolveTunnelProfile?: TunnelProfileResolver): number { const baseTimeoutSecs = positiveSeconds(config.connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS); const agentMinTimeoutSecs = config.db_type === "access" ? ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS : AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS; const timeouts = [DRIVER_STARTUP_FLOOR_TYPES.has(config.db_type as DatabaseType) ? Math.max(baseTimeoutSecs, agentMinTimeoutSecs) : baseTimeoutSecs]; - for (const layer of config.transport_layers ?? []) { + for (const unresolvedLayer of config.transport_layers ?? []) { + const layer = resolvedTimeoutLayer(unresolvedLayer, resolveTunnelProfile); if (layer.enabled === false) continue; if (layer.type === "ssh" || layer.type === "http_tunnel") { timeouts.push(positiveSeconds(layer.connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS)); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index fb9d7e919..0e7c52d72 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -170,6 +170,75 @@ describe("connectionStore timeout recovery", () => { expect(node.isLoading).toBe(false); }, 10_000); + it("uses a shared SSH profile timeout and cleans up a late backend success", async () => { + let resolveConnect!: (connectionId: string) => void; + const connectDb = vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + ); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + connectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + disconnectDb, + listInstalledAgents: vi.fn().mockResolvedValue([]), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useTunnelProfileStore } = await import("@/stores/tunnelProfileStore"); + const { useConnectionStore } = await import("@/stores/connectionStore"); + useTunnelProfileStore().profiles = [ + { + type: "ssh", + id: "slow-bastion", + host: "bastion.example.com", + port: 22, + user: "dbx", + connect_timeout_secs: 4, + }, + ]; + const store = useConnectionStore(); + const connection = postgresConnection({ + connect_timeout_secs: 1, + transport_layers: [ + { + type: "ssh", + id: "connection-hop", + profile_id: "slow-bastion", + host: "", + port: 22, + user: "root", + connect_timeout_secs: 1, + }, + ], + }); + store.connections = [connection]; + + let settled = false; + const connect = store.connect(connection).catch((error) => error); + void connect.finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(3001); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(3000); + const error = await connect; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("timed out after 6s"); + + resolveConnect(connection.id); + await vi.advanceTimersByTimeAsync(1); + expect(disconnectDb).toHaveBeenCalledWith(connection.id); + expect(store.connectedIds.has(connection.id)).toBe(false); + }, 10_000); + it("allows reconnecting the same connection while a scoped cancel is pending", async () => { let resolveDisconnect!: () => void; const pendingConnect = new Promise(() => undefined); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index ea56e60bd..6c70a74b0 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -237,6 +237,7 @@ function metadataDriverProfile(config?: ConnectionConfig): string | undefined { export const useConnectionStore = defineStore("connection", () => { const settingsStore = useSettingsStore(); + const tunnelProfileStore = useTunnelProfileStore(); const connections = ref([]); const isDesktop = isTauriRuntime(); const activeConnectionId = ref(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)); @@ -801,7 +802,7 @@ export const useConnectionStore = defineStore("connection", () => { } async function withConnectionAttemptTimeout(promise: Promise, config: ConnectionConfig): Promise { - const timeoutMs = connectionAttemptTimeoutMs(config); + const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById); const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); let timedOut = false; let timer: ReturnType | undefined; @@ -5395,8 +5396,8 @@ export const useConnectionStore = defineStore("connection", () => { async function initFromDisk() { if (!initFromDiskPromise) { initFromDiskPromise = (async () => { - pinnedTreeNodeIds.value = await loadPinnedTreeNodeIds(); - const saved = await api.loadConnections(); + const [pinnedIds, saved] = await Promise.all([loadPinnedTreeNodeIds(), api.loadConnections(), tunnelProfileStore.init()]); + pinnedTreeNodeIds.value = pinnedIds; connections.value = saved.map(normalizeConnection); const savedLayout = await api.loadSidebarLayout(); const currentLayout = sidebarLayout.value.groups.length || sidebarLayout.value.order.length ? sidebarLayout.value : null; diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index fa771021b..197b95948 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -377,17 +377,8 @@ pub async fn connect_standalone( timeout: std::time::Duration, ) -> Result { let mut last_error = None; - for auth in redis_auth_candidates(&config.username, &config.password) { - let client = redis::Client::open(connection_info( - host, - port, - config.ssl, - config.redis_tls_insecure(), - &auth.username, - &auth.password, - redis_database_index(config), - )) - .map_err(|e| format!("Redis connection failed: {e}"))?; + for info in standalone_connection_infos(config, host, port) { + let client = redis::Client::open(info).map_err(|e| format!("Redis connection failed: {e}"))?; match connect_client_with_timeout(client, timeout, "Redis").await { Ok(con) => return Ok(con), Err(err) if last_error.is_none() || is_redis_auth_error(&err) => { @@ -403,6 +394,23 @@ pub async fn connect_standalone( Err(last_error.unwrap_or_else(|| "Redis connection failed".to_string())) } +fn standalone_connection_infos(config: &ConnectionConfig, host: &str, port: u16) -> Vec { + redis_auth_candidates(&config.username, &config.password) + .into_iter() + .map(|auth| { + connection_info( + host, + port, + config.ssl, + config.redis_tls_insecure(), + &auth.username, + &auth.password, + redis_database_index(config), + ) + }) + .collect() +} + async fn connect_client_with_timeout( client: redis::Client, timeout: std::time::Duration, @@ -2948,9 +2956,10 @@ mod tests { parse_stream_entries, redis_auth_candidates, redis_blob_from_bytes, redis_cluster_slot, redis_command_raw_to_json, redis_database_index, redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_sentinel_master_endpoint, - redis_value_matches_query, redis_value_to_bytes, RedisAuthCandidate, RedisBlob, RedisBlobEncoding, - RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint, - RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamEntry, RedisStreamField, RedisValue, RedisValueData, + redis_value_matches_query, redis_value_to_bytes, standalone_connection_infos, RedisAuthCandidate, RedisBlob, + RedisBlobEncoding, RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem, + RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamEntry, RedisStreamField, RedisValue, + RedisValueData, }; use crate::models::connection::ConnectionConfig; use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture}; @@ -4028,9 +4037,44 @@ mod tests { ); } + #[test] + fn standalone_connection_infos_cover_no_auth_acl_fallback_tls_and_database() { + let mut config = redis_test_connection_config(); + let no_auth = standalone_connection_infos(&config, "127.0.0.1", 6379); + assert_eq!(no_auth.len(), 1); + assert_eq!(no_auth[0].redis.username, None); + assert_eq!(no_auth[0].redis.password, None); + assert_eq!(no_auth[0].redis.db, 0); + + config.username = "app-user".to_string(); + config.password = "secret".to_string(); + config.database = Some("4".to_string()); + config.ssl = true; + config.url_params = Some("tls_insecure=true".to_string()); + let infos = standalone_connection_infos(&config, "cache.example.com", 6380); + + assert_eq!(infos.len(), 2); + assert!(matches!(infos[0].addr, ConnectionAddr::TcpTls { port: 6380, insecure: true, .. })); + assert_eq!(infos[0].redis.username.as_deref(), Some("app-user")); + assert_eq!(infos[0].redis.password.as_deref(), Some("secret")); + assert_eq!(infos[0].redis.db, 4); + assert_eq!(infos[1].redis.username, None); + assert_eq!(infos[1].redis.password.as_deref(), Some("app-user@secret")); + assert_eq!(infos[1].redis.db, 4); + } + #[test] fn redis_database_index_uses_numeric_database_only() { - let mut config = ConnectionConfig { + let mut config = redis_test_connection_config(); + config.database = Some("4".to_string()); + + assert_eq!(redis_database_index(&config), 4); + config.database = Some("not-a-number".to_string()); + assert_eq!(redis_database_index(&config), 0); + } + + fn redis_test_connection_config() -> ConnectionConfig { + ConnectionConfig { id: "redis".to_string(), name: "Redis".to_string(), db_type: crate::models::connection::DatabaseType::Redis, @@ -4042,7 +4086,7 @@ mod tests { port: 6379, username: String::new(), password: String::new(), - database: Some("4".to_string()), + database: None, visible_databases: None, visible_schemas: None, attached_databases: Vec::new(), @@ -4080,11 +4124,7 @@ mod tests { is_production: false, production_databases: vec![], database_info: None, - }; - - assert_eq!(redis_database_index(&config), 4); - config.database = Some("not-a-number".to_string()); - assert_eq!(redis_database_index(&config), 0); + } } #[test] diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 6869aaa88..a254a0ec9 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -692,6 +692,26 @@ async fn connect_sqlite_from_config(config: &ConnectionConfig) -> Result, + tunnel_id: &str, + config: &ConnectionConfig, + host: &str, + port: u16, + connect_timeout: std::time::Duration, +) -> Result { + // Connection tests must exercise the same Redis lifecycle as a saved connection, + // including compatibility auth, TLS, and database selection. + if config.uses_redis_cluster() { + drop(state.connect_redis_cluster(tunnel_id, config).await?); + } else if config.uses_redis_sentinel() { + drop(state.connect_redis_sentinel(tunnel_id, config).await?); + } else { + drop(db::redis_driver::connect_standalone(config, host, port, connect_timeout).await?); + } + Ok("Connection successful".to_string()) +} + #[tauri::command] pub async fn test_connection(state: State<'_, Arc>, config: ConnectionConfig) -> Result { test_connection_with_info_inner(state.inner(), config).await.map(|result| result.message) @@ -812,17 +832,9 @@ async fn test_connection_with_info_inner( Err(e) => Err(e), }, DatabaseType::Redis => { - let con = if config.uses_redis_cluster() { - state.connect_redis_cluster(&tunnel_id, &config).await?; - return Ok(ConnectionTestResult::success("Connection successful")); - } else if config.uses_redis_sentinel() { - state.connect_redis_sentinel(&tunnel_id, &config).await?; - return Ok(ConnectionTestResult::success("Connection successful")); - } else { - db::redis_driver::connect(&url, connect_timeout).await? - }; - drop(con); - Ok("Connection successful".to_string()) + // Keep the result inside the outer lifecycle so temporary transports + // are reset after both successful and failed Redis tests. + test_redis_connection(state, &tunnel_id, &config, &host, port, connect_timeout).await } #[cfg(feature = "duckdb-bundled")] DatabaseType::DuckDb => { @@ -1114,7 +1126,7 @@ pub async fn connect_db( ))) } else { PoolKind::Redis(db::redis_driver::RedisConnection::Direct(tokio::sync::Mutex::new( - db::redis_driver::connect(&url, connect_timeout).await?, + db::redis_driver::connect_standalone(&db_config, &host, port, connect_timeout).await?, ))) }; con