From 2b26bd6a1a55552ad2acf371c49aac5a113249cb Mon Sep 17 00:00:00 2001 From: zhangyc Date: Wed, 9 Sep 2026 22:36:13 +0800 Subject: [PATCH] fix(connection): stop background search auto-reconnect --- .../__tests__/connectionStore.timeout.spec.ts | 94 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 6 ++ 2 files changed, 100 insertions(+) diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index 2d4d9ead9c..4a2ad6b1b0 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from "pinia"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConnectionConfig, TreeNode } from "@/types/database"; +import { runSidebarSearchTasks } from "@/components/sidebar/sidebarSearchTaskRunner"; function installLocalStorage() { const data = new Map(); @@ -93,6 +94,99 @@ describe("connectionStore timeout recovery", () => { expect(store.connectedIds.has(connection.id)).toBe(true); }); + it.each(["failure", "success", "timeout"] as const)("ignores an older health check %s after disconnect", async (outcome) => { + let resolveHealth!: () => void; + let rejectHealth!: (error: Error) => void; + const checkConnectionHealth = vi.fn( + () => + new Promise((resolve, reject) => { + resolveHealth = resolve; + rejectHealth = reject; + }), + ); + const connectDb = vi.fn().mockResolvedValue("pg-1"); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + connectDb, + disconnectDb: vi.fn().mockResolvedValue(undefined), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + const { useConnectionStore, CONNECTION_ATTEMPT_CANCELLED_MESSAGE } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + const ensure = store.ensureConnected(connection.id).catch((error) => error); + await store.disconnect(connection.id); + if (outcome === "failure") rejectHealth(new Error("pool closed")); + else if (outcome === "success") resolveHealth(); + else await vi.advanceTimersByTimeAsync(5001); + expect(await ensure).toEqual(new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)); + expect(connectDb).not.toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(false); + // An explicit reconnect still works after the stale probe has been discarded. + await store.ensureConnected(connection.id); + expect(connectDb).toHaveBeenCalledTimes(1); + expect(store.connectedIds.has(connection.id)).toBe(true); + }); + + it("does not reconnect for queued sidebar object loads after disconnect", async () => { + let finishTables!: (tables: []) => void; + const listTables = vi.fn( + () => + new Promise<[]>((resolve) => { + finishTables = resolve; + }), + ); + const connectDb = vi.fn().mockResolvedValue("pg-1"); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + connectDb, + disconnectDb: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection(); + const groups: TreeNode[] = ["TABLE", "VIEW"].map((kind) => ({ + id: `${connection.id}:app:public:__${kind.toLowerCase()}s`, + label: kind, + type: kind === "TABLE" ? "group-tables" : "group-views", + connectionId: connection.id, + database: "app", + schema: "public", + children: [], + })); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: groups }]; + const queued = runSidebarSearchTasks( + groups.map((node) => () => store.loadObjectGroupChildren(node, { force: true })), + 1, + ); + await vi.advanceTimersByTimeAsync(1); + expect(listTables).toHaveBeenCalledTimes(1); + await store.disconnect(connection.id); + finishTables([]); + await vi.advanceTimersByTimeAsync(1); + expect(connectDb).not.toHaveBeenCalled(); + await queued; + expect(listTables).toHaveBeenCalledTimes(1); + expect(store.connectedIds.has(connection.id)).toBe(false); + expect(store.treeNodes[0].children).toEqual([]); + }); + it("normalizes missing keepalive interval to 30 seconds", async () => { vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a1add2e27e..999eb3d23b 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -4093,12 +4093,16 @@ export const useConnectionStore = defineStore("connection", () => { // a health probe makes an otherwise local tab switch take up to 5s. if (options.verifyHealth === false) return; if (hasRecentConnectionHealthCheck(connectionId)) return; + const stateRevision = connectionStateRevision(connectionId); // Optimistic: verify backend pool is actually healthy try { await withConnectionHealthTimeout(connectionId, api.checkConnectionHealth(connectionId)); + if (!isCurrentConnectionStateRevision(connectionId, stateRevision)) throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); markConnectionHealthChecked(connectionId); return; } catch { + // A late probe must not undo an explicit disconnect or a newer reconnect. + if (!isCurrentConnectionStateRevision(connectionId, stateRevision)) throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); // Backend pool is dead — remove from connectedIds and reconnect. // 死池重连同样跨越了连接生命周期:shared 表元数据缓存与数据标签页的 // 元数据 freshness 都必须作废,否则自动重连后仍可能复用断链前的旧 @@ -5694,6 +5698,8 @@ export const useConnectionStore = defineStore("connection", () => { } async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) { + // Queued search/refresh tasks can outlive disconnect, which removes their nodes. + if (!treeNodeInSidebarTree(node)) return; const packageOwnerId = packageMemberGroupOwnerId(node); const packageConfig = node.connectionId ? getConfig(node.connectionId) : undefined; if (packageOwnerId && effectiveDatabaseTypeForConnection(packageConfig) === "xugu") {