From 7bc358d612651eec6fc479d1f14d80b0bb8f29dd Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Sat, 21 Mar 2026 16:17:29 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(connect):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=A6=96=E6=AC=A1=E5=90=AF=E5=8A=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=BF=9E=E6=8E=A5=E5=81=B6=E5=8F=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 51 +++++- frontend/src/utils/startupReadiness.test.ts | 26 +++ frontend/src/utils/startupReadiness.ts | 26 +++ internal/app/app.go | 157 +++++++++++++++--- .../app/app_startup_connect_retry_test.go | 149 +++++++++++++++++ 5 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 frontend/src/utils/startupReadiness.test.ts create mode 100644 frontend/src/utils/startupReadiness.ts create mode 100644 internal/app/app_startup_connect_retry_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 364b4b3..59fe743 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; +import { getConnectionWorkbenchState } from './utils/startupReadiness'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -90,9 +91,11 @@ function App() { const [runtimePlatform, setRuntimePlatform] = useState(''); const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); + const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false); const sidebarWidth = useStore(state => state.sidebarWidth); const setSidebarWidth = useStore(state => state.setSidebarWidth); const globalProxyInvalidHintShownRef = React.useRef(false); + const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 @@ -198,8 +201,16 @@ function App() { content: '全局代理配置失败: ' + errMsg, key: 'global-proxy-sync-error', }); + }) + .finally(() => { + if (!cancelled) { + setHasAppliedInitialGlobalProxy(true); + } }); } catch (e) { + if (!cancelled) { + setHasAppliedInitialGlobalProxy(true); + } console.warn("Wails API: ConfigureGlobalProxy unavailable", e); } @@ -1638,8 +1649,44 @@ function App() { -
- +
+
+ +
+ {!connectionWorkbenchState.ready && ( +
+
+ + {connectionWorkbenchState.message} +
+
+ )}
{/* Floating SQL Log Toggle */} diff --git a/frontend/src/utils/startupReadiness.test.ts b/frontend/src/utils/startupReadiness.test.ts new file mode 100644 index 0000000..92c72bd --- /dev/null +++ b/frontend/src/utils/startupReadiness.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { getConnectionWorkbenchState } from './startupReadiness'; + +describe('startup readiness helpers', () => { + it('blocks sidebar interactions before local store hydration completes', () => { + expect(getConnectionWorkbenchState(false, false)).toEqual({ + ready: false, + message: '正在加载本地配置...', + }); + }); + + it('keeps sidebar blocked until initial global proxy sync finishes', () => { + expect(getConnectionWorkbenchState(true, false)).toEqual({ + ready: false, + message: '正在同步全局代理配置...', + }); + }); + + it('unblocks sidebar after startup configuration is fully applied', () => { + expect(getConnectionWorkbenchState(true, true)).toEqual({ + ready: true, + message: '', + }); + }); +}); diff --git a/frontend/src/utils/startupReadiness.ts b/frontend/src/utils/startupReadiness.ts new file mode 100644 index 0000000..3395627 --- /dev/null +++ b/frontend/src/utils/startupReadiness.ts @@ -0,0 +1,26 @@ +export interface ConnectionWorkbenchState { + ready: boolean; + message: string; +} + +export function getConnectionWorkbenchState( + isStoreHydrated: boolean, + hasAppliedInitialGlobalProxy: boolean +): ConnectionWorkbenchState { + if (!isStoreHydrated) { + return { + ready: false, + message: '正在加载本地配置...', + }; + } + if (!hasAppliedInitialGlobalProxy) { + return { + ready: false, + message: '正在同步全局代理配置...', + }; + } + return { + ready: true, + message: '', + }; +} diff --git a/internal/app/app.go b/internal/app/app.go index 98238c9..8f6ae70 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,17 @@ import ( const dbCachePingInterval = 30 * time.Second +const ( + startupConnectRetryWindow = 20 * time.Second + startupConnectRetryDelay = 800 * time.Millisecond + startupConnectRetryAttempts = 4 +) + +var ( + newDatabaseFunc = db.NewDatabase + resolveDialConfigWithProxyFunc = resolveDialConfigWithProxy +) + type cachedDatabase struct { inst db.Database lastPing time.Time @@ -36,6 +47,7 @@ type queryContext struct { // App struct type App struct { ctx context.Context + startedAt time.Time dbCache map[string]cachedDatabase // Cache for DB connections mu sync.RWMutex // Mutex for cache access updateMu sync.Mutex @@ -56,9 +68,10 @@ func NewApp() *App { // so we can call the runtime methods func (a *App) Startup(ctx context.Context) { a.ctx = ctx + a.startedAt = time.Now() logger.Init() applyMacWindowTranslucencyFix() - logger.Infof("应用启动完成") + logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } // SetWindowTranslucency 动态调整 macOS 窗口透明度。 @@ -429,12 +442,12 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()} } - dbInst, err := db.NewDatabase(effectiveConfig.Type) + dbInst, err := newDatabaseFunc(effectiveConfig.Type) if err != nil { return nil, err } - connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) if proxyErr != nil { _ = dbInst.Close() return nil, wrapConnectError(effectiveConfig, proxyErr) @@ -451,10 +464,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing isFileDB := isFileDatabaseType(effectiveConfig.Type) key := getCacheKey(effectiveConfig) - shortKey := key - if len(shortKey) > 12 { - shortKey = shortKey[:12] - } + shortKey := shortenCacheKey(key) if isFileDB { rawDSN := resolveFileDatabaseDSN(effectiveConfig) normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig)) @@ -531,26 +541,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } - logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) - dbInst, err := db.NewDatabase(effectiveConfig.Type) + dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(config) if err != nil { - logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey) return nil, err } - - connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) - if proxyErr != nil { - wrapped := wrapConnectError(effectiveConfig, proxyErr) - logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - return nil, wrapped - } - - if err := dbInst.Connect(connectConfig); err != nil { - wrapped := wrapConnectError(effectiveConfig, err) - logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) - return nil, wrapped - } + effectiveConfig = connectedConfig + key = getCacheKey(effectiveConfig) + shortKey = shortenCacheKey(key) now := time.Now() @@ -571,6 +568,118 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing return dbInst, nil } +func shortenCacheKey(key string) string { + if len(key) > 12 { + return key[:12] + } + return key +} + +func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) { + var lastErr error + var lastEffectiveConfig connection.ConnectionConfig + + for attempt := 1; attempt <= startupConnectRetryAttempts; attempt++ { + effectiveConfig := applyGlobalProxyToConnection(rawConfig) + lastEffectiveConfig = effectiveConfig + cacheKey := shortenCacheKey(getCacheKey(effectiveConfig)) + + logger.Infof("获取数据库连接:%s 缓存Key=%s 启动阶段=%s", formatConnSummary(effectiveConfig), cacheKey, a.startupPhaseLabel()) + logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s 尝试=%d/%d", effectiveConfig.Type, cacheKey, attempt, startupConnectRetryAttempts) + + dbInst, err := newDatabaseFunc(effectiveConfig.Type) + if err != nil { + logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, cacheKey) + return nil, effectiveConfig, err + } + + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) + if proxyErr != nil { + _ = dbInst.Close() + wrapped := wrapConnectError(effectiveConfig, proxyErr) + logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) + return nil, effectiveConfig, wrapped + } + + if err := dbInst.Connect(connectConfig); err == nil { + if attempt > 1 { + logger.Warnf("数据库连接在启动保护重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts) + } + return dbInst, effectiveConfig, nil + } else { + _ = dbInst.Close() + wrapped := wrapConnectError(effectiveConfig, err) + lastErr = wrapped + logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) + if !a.shouldRetryStartupConnect(err, attempt) { + return nil, effectiveConfig, wrapped + } + logger.Warnf("检测到启动期瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s", + formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err)) + time.Sleep(startupConnectRetryDelay) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("建立数据库连接失败") + } + return nil, lastEffectiveConfig, lastErr +} + +func (a *App) startupPhaseLabel() string { + if a == nil || a.startedAt.IsZero() { + return "未知" + } + age := time.Since(a.startedAt).Round(time.Millisecond) + if age < 0 { + age = 0 + } + if age <= startupConnectRetryWindow { + snapshot := currentGlobalProxyConfig() + state := "关闭" + if snapshot.Enabled { + state = fmt.Sprintf("启用(%s://%s:%d)", strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), strings.TrimSpace(snapshot.Proxy.Host), snapshot.Proxy.Port) + } + return fmt.Sprintf("启动期(age=%s,全局代理=%s)", age, state) + } + return fmt.Sprintf("稳定期(age=%s)", age) +} + +func (a *App) shouldRetryStartupConnect(err error, attempt int) bool { + if attempt >= startupConnectRetryAttempts { + return false + } + if a == nil || a.startedAt.IsZero() { + return false + } + age := time.Since(a.startedAt) + if age < 0 || age > startupConnectRetryWindow { + return false + } + return isTransientStartupConnectError(err) +} + +func isTransientStartupConnectError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(normalizeErrorMessage(err)) + transientHints := []string{ + "no route to host", + "network is unreachable", + "connection refused", + "connection timed out", + "i/o timeout", + "context deadline exceeded", + } + for _, hint := range transientHints { + if strings.Contains(message, hint) { + return true + } + } + return false +} + // generateQueryID generates a unique ID for a query using UUID v4 func generateQueryID() string { return "query-" + uuid.New().String() diff --git a/internal/app/app_startup_connect_retry_test.go b/internal/app/app_startup_connect_retry_test.go new file mode 100644 index 0000000..a840b30 --- /dev/null +++ b/internal/app/app_startup_connect_retry_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "errors" + "testing" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" +) + +type fakeStartupRetryDB struct { + connect func(config connection.ConnectionConfig) error +} + +func (f *fakeStartupRetryDB) Connect(config connection.ConnectionConfig) error { + if f.connect != nil { + return f.connect(config) + } + return nil +} + +func (f *fakeStartupRetryDB) Close() error { return nil } +func (f *fakeStartupRetryDB) Ping() error { return nil } +func (f *fakeStartupRetryDB) Query(query string) ([]map[string]interface{}, []string, error) { + return nil, nil, nil +} +func (f *fakeStartupRetryDB) Exec(query string) (int64, error) { return 0, nil } +func (f *fakeStartupRetryDB) GetDatabases() ([]string, error) { return nil, nil } +func (f *fakeStartupRetryDB) GetTables(dbName string) ([]string, error) { return nil, nil } +func (f *fakeStartupRetryDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (f *fakeStartupRetryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (f *fakeStartupRetryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlobalProxy(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + snapshot := currentGlobalProxyConfig() + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(snapshot.Enabled, snapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + }() + + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + seenConfigs := make([]connection.ConnectionConfig, 0, 2) + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + seenConfigs = append(seenConfigs, config) + if connectCalls == 1 { + _, _ = setGlobalProxyConfig(true, connection.ProxyConfig{Type: "socks5", Host: "127.0.0.1", Port: 1080}) + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now()} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, effectiveConfig, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err != nil { + t.Fatalf("connectDatabaseWithStartupRetry returned error: %v", err) + } + if connectCalls != 2 { + t.Fatalf("expected 2 connect attempts, got %d", connectCalls) + } + if len(seenConfigs) != 2 { + t.Fatalf("expected 2 seen configs, got %d", len(seenConfigs)) + } + if seenConfigs[0].UseProxy { + t.Fatalf("expected first attempt without proxy, got %+v", seenConfigs[0]) + } + if !seenConfigs[1].UseProxy { + t.Fatalf("expected second attempt with proxy after startup retry, got %+v", seenConfigs[1]) + } + if !effectiveConfig.UseProxy { + t.Fatalf("expected returned effective config to include proxy, got %+v", effectiveConfig) + } +} + +func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)} + rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, _, err := a.connectDatabaseWithStartupRetry(rawConfig) + if err == nil { + t.Fatal("expected error, got nil") + } + if connectCalls != 1 { + t.Fatalf("expected 1 connect attempt outside startup window, got %d", connectCalls) + } +} + +func TestIsTransientStartupConnectError(t *testing.T) { + if !isTransientStartupConnectError(errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")) { + t.Fatal("expected no route to host to be treated as transient startup connect error") + } + if isTransientStartupConnectError(errors.New("pq: password authentication failed")) { + t.Fatal("expected authentication failure to not be treated as transient startup connect error") + } +}