diff --git a/apps/desktop/src/lib/connectionAttemptTimeout.ts b/apps/desktop/src/lib/connectionAttemptTimeout.ts new file mode 100644 index 0000000000..26a74e272e --- /dev/null +++ b/apps/desktop/src/lib/connectionAttemptTimeout.ts @@ -0,0 +1,25 @@ +import type { ConnectionConfig } from "@/types/database"; + +export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000; +const DEFAULT_CONNECT_TIMEOUT_SECS = 5; + +function positiveSeconds(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; +} + +export function connectionAttemptTimeoutMs( + config: Pick, +): number { + const timeouts = [positiveSeconds(config.connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS)]; + if (config.ssh_enabled) { + timeouts.push(positiveSeconds(config.ssh_connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS)); + for (const tunnel of config.ssh_tunnels ?? []) { + timeouts.push(positiveSeconds(tunnel.connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS)); + } + } + return Math.ceil(Math.max(...timeouts) * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS); +} + +export function connectionAttemptTimeoutMessage(timeoutMs: number): string { + return `Connection attempt timed out after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b41891923f..8434bc0bb5 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -25,6 +25,7 @@ import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes } from "@/lib/da import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree"; import { findDatabaseTreeNode } from "@/lib/treeRefreshTarget"; import { shouldMarkDisconnected } from "@/lib/connectionHealth"; +import { connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connectionAttemptTimeout"; import { filterDatabaseNamesForConnection, filterVisibleDatabaseNames, @@ -195,6 +196,21 @@ export const useConnectionStore = defineStore("connection", () => { recordConnectionError(connectionId, error); } + async function withConnectionAttemptTimeout(promise: Promise, config: ConnectionConfig): Promise { + const timeoutMs = connectionAttemptTimeoutMs(config); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(connectionAttemptTimeoutMessage(timeoutMs))), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + function normalizeConnection(config: ConnectionConfig): ConnectionConfig { const labelMap: Record = { mysql: "MySQL", @@ -642,7 +658,7 @@ export const useConnectionStore = defineStore("connection", () => { if (pendingNode) pendingNode.isLoading = true; try { await beforeConnectHandler?.(config); - const id = await api.connectDb(config); + const id = await withConnectionAttemptTimeout(api.connectDb(config), config); activeConnectionId.value = id; connectedIds.value.add(id); clearConnectionError(config.id); @@ -720,7 +736,7 @@ export const useConnectionStore = defineStore("connection", () => { } try { await beforeConnectHandler?.(config); - await api.connectDb(config); + await withConnectionAttemptTimeout(api.connectDb(config), config); connectedIds.value.add(connectionId); activeConnectionId.value = connectionId; clearConnectionError(connectionId); diff --git a/packages/app-tests/connectionAttemptTimeout.test.ts b/packages/app-tests/connectionAttemptTimeout.test.ts new file mode 100644 index 0000000000..f9b09419e9 --- /dev/null +++ b/packages/app-tests/connectionAttemptTimeout.test.ts @@ -0,0 +1,33 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS, + connectionAttemptTimeoutMessage, + connectionAttemptTimeoutMs, +} from "../../apps/desktop/src/lib/connectionAttemptTimeout.ts"; + +test("uses connection timeout with a small UI buffer", () => { + assert.equal(connectionAttemptTimeoutMs({ connect_timeout_secs: 8 }), 8_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS); +}); + +test("falls back to the default connection timeout", () => { + assert.equal(connectionAttemptTimeoutMs({}), 5_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS); + assert.equal(connectionAttemptTimeoutMs({ connect_timeout_secs: 0 }), 5_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS); +}); + +test("honors slower SSH tunnel connection timeouts", () => { + assert.equal( + connectionAttemptTimeoutMs({ + connect_timeout_secs: 5, + ssh_enabled: true, + ssh_connect_timeout_secs: 12, + ssh_tunnels: [{ id: "hop-1", host: "bastion", port: 22, user: "dbx", connect_timeout_secs: 20 }], + }), + 20_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS, + ); +}); + +test("formats connection attempt timeout messages", () => { + assert.match(connectionAttemptTimeoutMessage(7_001), /timed out after 8s/); + assert.match(connectionAttemptTimeoutMessage(7_001), /VPN/); +});