fix(redis): align test and saved connection paths

This commit is contained in:
t8y2 2026-07-22 15:01:18 +08:00
parent f6bba1cb6b
commit 96736d3dbf
7 changed files with 239 additions and 40 deletions

View File

@ -1428,7 +1428,8 @@ async function persistSuccessfulConnectionTest(result: ConnectionTestResult, con
}
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<ConnectionTestResult> {
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;

View File

@ -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({

View File

@ -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<ConnectionConfig, "connect_timeout_secs" | "transport_layers"> & Partial<Pick<ConnectionConfig, "db_type">>): 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<ConnectionConfig, "connect_timeout_secs" | "transport_layers"> & Partial<Pick<ConnectionConfig, "db_type">>, 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));

View File

@ -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<string>((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<string>(() => undefined);

View File

@ -237,6 +237,7 @@ function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
export const useConnectionStore = defineStore("connection", () => {
const settingsStore = useSettingsStore();
const tunnelProfileStore = useTunnelProfileStore();
const connections = ref<ConnectionConfig[]>([]);
const isDesktop = isTauriRuntime();
const activeConnectionId = ref<string | null>(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY));
@ -801,7 +802,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function withConnectionAttemptTimeout<T>(promise: Promise<T>, config: ConnectionConfig): Promise<T> {
const timeoutMs = connectionAttemptTimeoutMs(config);
const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById);
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | 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;

View File

@ -377,17 +377,8 @@ pub async fn connect_standalone(
timeout: std::time::Duration,
) -> Result<redis::aio::MultiplexedConnection, String> {
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<ConnectionInfo> {
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]

View File

@ -692,6 +692,26 @@ async fn connect_sqlite_from_config(config: &ConnectionConfig) -> Result<db::sql
Ok(pool)
}
async fn test_redis_connection(
state: &Arc<AppState>,
tunnel_id: &str,
config: &ConnectionConfig,
host: &str,
port: u16,
connect_timeout: std::time::Duration,
) -> Result<String, String> {
// 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<AppState>>, config: ConnectionConfig) -> Result<String, String> {
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