diff --git a/frontend/src/components/DriverManagerModal.test.tsx b/frontend/src/components/DriverManagerModal.test.tsx index 47ca2d7..8033330 100644 --- a/frontend/src/components/DriverManagerModal.test.tsx +++ b/frontend/src/components/DriverManagerModal.test.tsx @@ -201,4 +201,33 @@ describe('DriverManagerModal toolbar actions', () => { expect(importDirButtonAfter.props.disabled).toBeFalsy(); expect(installAllButtonAfter.props.disabled).toBe(true); }); + + it('releases install action when the driver install watchdog expires', async () => { + vi.useFakeTimers(); + try { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + const installButton = findButton(renderer!, '安装启用'); + await act(async () => { + installButton.props.onClick(); + await Promise.resolve(); + }); + + expect(findButton(renderer!, '安装启用').props.disabled).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(12 * 60 * 1000); + await Promise.resolve(); + }); + + expect(findButton(renderer!, '安装启用').props.disabled).toBeFalsy(); + expect(messageApi.error).toHaveBeenCalledWith(expect.stringContaining('超过 12 分钟仍未完成')); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 477cdae..ca1550b 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -123,6 +123,7 @@ const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`; const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000; const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000; +const DRIVER_INSTALL_WATCHDOG_MS = 12 * 60 * 1000; const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase(); const isSlimBuildInstallUnavailable = (row: DriverStatusRow) => (row.message || '').includes('精简构建') && !row.packageInstalled; const resolveDriverBatchActionLabel = (actionKind: DriverBatchActionKind) => { @@ -703,6 +704,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG percent: 0, }); appendOperationLog(row.type, '[START] 开始自动安装'); + let watchdogId: ReturnType | undefined; try { let versionOptions = versionMap[row.type] || []; if (versionOptions.length === 0) { @@ -716,7 +718,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const selectedVersion = selectedOption?.version || row.pinnedVersion || ''; const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || ''; - const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir); + const installWatchdog = new Promise((_, reject) => { + watchdogId = setTimeout(() => { + reject(new Error(`安装 ${row.name} 超过 ${Math.round(DRIVER_INSTALL_WATCHDOG_MS / 60000)} 分钟仍未完成。后台任务可能仍在下载或构建,请稍后刷新状态;如多次出现,请检查代理或改用本地驱动包导入。`)); + }, DRIVER_INSTALL_WATCHDOG_MS); + }); + const result = await Promise.race([ + DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir), + installWatchdog, + ]); + if (watchdogId) { + clearTimeout(watchdogId); + } if (!result?.success) { const errText = result?.message || `安装 ${row.name} 失败`; appendOperationLog(row.type, `[ERROR] ${errText}`); @@ -734,7 +747,22 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG await refreshStatus(false); } return true; + } catch (error) { + const errText = error instanceof Error ? error.message : String(error || `安装 ${row.name} 失败`); + appendOperationLog(row.type, `[ERROR] ${errText}`); + updateDriverProgress(row.type, { + status: 'error', + message: errText, + percent: 0, + }); + if (!actionOptions?.silentToast) { + message.error(errText); + } + return false; } finally { + if (watchdogId) { + clearTimeout(watchdogId); + } setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, updateDriverProgress, versionMap]); diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 3cc6857..8a05026 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -3,6 +3,7 @@ package app import ( "archive/zip" "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -301,7 +302,7 @@ const ( optionalDriverBundleAssetName = "GoNavi-DriverAgents.zip" duckDBWindowsDriverZipAssetName = "duckdb-driver.zip" optionalDriverBundleIndexAssetName = "GoNavi-DriverAgents-Index.json" - optionalDriverBundleDownloadTimeout = 45 * time.Minute + optionalDriverBundleDownloadTimeout = 15 * time.Minute optionalDriverBundleCacheMaxAge = 7 * 24 * time.Hour optionalDriverBundleCacheMaxFiles = 4 driverManifestCacheTTL = 5 * time.Minute @@ -373,6 +374,8 @@ var ( errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded") ) +var optionalDriverSourceBuildTimeout = 8 * time.Minute + var validateOptionalDriverAgentExecutableFunc = db.ValidateOptionalDriverAgentExecutable type driverVersionWarmupState struct { @@ -3829,10 +3832,15 @@ func buildOptionalDriverAgentFromSource(definition driverDefinition, executableP env = prependPathEnv(env, duckDBLibDir) } buildArgs = append(buildArgs, "-o", executablePath, "./cmd/optional-driver-agent") - cmd := exec.Command(goPath, buildArgs...) + ctx, cancel := context.WithTimeout(context.Background(), optionalDriverSourceBuildTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, goPath, buildArgs...) cmd.Dir = projectRoot cmd.Env = env output, buildErr := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("构建 %s 驱动代理超时(超过 %s),请优先使用预编译驱动包或本地驱动包导入", displayName, optionalDriverSourceBuildTimeout) + } if buildErr != nil { return "", fmt.Errorf("构建 %s 驱动代理失败:%v,输出:%s", displayName, buildErr, strings.TrimSpace(string(output))) } @@ -3978,26 +3986,15 @@ func shouldPreferSourceBuildBeforeDownload(driverType string, selectedVersion st func shouldPreferSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { _ = selectedVersion - if shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) { - return true - } + _ = buildType return shouldUseDuckDBWindowsDynamicLibrary(driverType) } func shouldRequireSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { _ = selectedVersion - if normalizeDriverType(driverType) == "duckdb" { - return false - } - return shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) -} - -func shouldPreferDevelopmentDriverAgentSourceBuild(buildType string, driverType string) bool { - normalizedBuildType := strings.ToLower(strings.TrimSpace(buildType)) - if normalizedBuildType != "dev" && normalizedBuildType != "development" { - return false - } - return db.IsOptionalGoDriver(driverType) + _ = buildType + _ = driverType + return false } func shouldSkipReusableAgentCandidate(driverType string, selectedVersion string) bool { diff --git a/internal/app/methods_driver_version_test.go b/internal/app/methods_driver_version_test.go index 36f852f..e82e26e 100644 --- a/internal/app/methods_driver_version_test.go +++ b/internal/app/methods_driver_version_test.go @@ -630,11 +630,11 @@ func TestShouldPreferSourceBuildBeforeDownloadDoesNotPreferKingbase(t *testing.T } func TestShouldPreferSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) { - if !shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "mariadb", "1.9.3") { - t.Fatal("expected development build to prefer local driver-agent source build") + if shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "mariadb", "1.9.3") { + t.Fatal("expected development release build to prefer published MariaDB driver-agent before source fallback") } - if !shouldPreferSourceBuildBeforeDownloadForBuildType("development", "clickhouse", "2.43.1") { - t.Fatal("expected development build alias to prefer local driver-agent source build") + if shouldPreferSourceBuildBeforeDownloadForBuildType("development", "clickhouse", "2.43.1") && !shouldUseDuckDBWindowsDynamicLibrary("clickhouse") { + t.Fatal("expected development build alias not to prefer source build for ClickHouse") } if shouldPreferSourceBuildBeforeDownloadForBuildType("production", "mariadb", "1.9.3") { t.Fatal("expected production build not to prefer source build for MariaDB") @@ -648,11 +648,15 @@ func TestShouldRequireSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) if shouldRequireSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { t.Fatal("expected development build to allow DuckDB release bundle fallback after local build failure") } - if !shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { - t.Fatal("expected development build to still prefer local DuckDB driver-agent source build before bundle fallback") + if shouldUseDuckDBWindowsDynamicLibrary("duckdb") { + if !shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { + t.Fatal("expected DuckDB Windows dynamic-library install to prefer local source build before bundle fallback") + } + } else if shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { + t.Fatal("expected development build not to prefer DuckDB source build on non-Windows dynamic-library platforms") } - if !shouldRequireSourceBuildBeforeDownloadForBuildType("development", "mariadb", "1.9.3") { - t.Fatal("expected development build alias to require local driver-agent source build") + if shouldRequireSourceBuildBeforeDownloadForBuildType("development", "mariadb", "1.9.3") { + t.Fatal("expected development build alias to allow published MariaDB driver-agent fallback") } if shouldRequireSourceBuildBeforeDownloadForBuildType("production", "duckdb", "2.5.6") { t.Fatal("expected production build to allow DuckDB release bundle fallback") @@ -662,6 +666,15 @@ func TestShouldRequireSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) } } +func TestOptionalDriverInstallTimeoutsStayBounded(t *testing.T) { + if optionalDriverBundleDownloadTimeout > 15*time.Minute { + t.Fatalf("driver bundle download timeout should stay bounded, got %s", optionalDriverBundleDownloadTimeout) + } + if optionalDriverSourceBuildTimeout > 8*time.Minute { + t.Fatalf("driver source build timeout should stay bounded, got %s", optionalDriverSourceBuildTimeout) + } +} + func TestResolveDuckDBWindowsCGOToolchainBinFromCandidates(t *testing.T) { binDir := t.TempDir() writeSelfExecutable(t, filepath.Join(binDir, "gcc.exe")) diff --git a/tools/detect-changed-driver-agents.sh b/tools/detect-changed-driver-agents.sh index 9d87f3d..3a45635 100644 --- a/tools/detect-changed-driver-agents.sh +++ b/tools/detect-changed-driver-agents.sh @@ -320,9 +320,31 @@ add_all_forced_drivers() { done } +revision_file_changed_drivers() { + local line driver emitted_seen + emitted_seen="|" + while IFS= read -r line; do + case "$line" in + +++*|---*|@@*) + continue + ;; + +*|-*) + if [[ "$line" =~ \"([^\"]+)\"[[:space:]]*:[[:space:]]*\"src-[^\"]+\" ]]; then + driver="$(normalize_driver "${BASH_REMATCH[1]}")" || continue + if [[ "$emitted_seen" == *"|$driver|"* ]]; then + continue + fi + printf '%s\n' "$driver" + emitted_seen="${emitted_seen}${driver}|" + fi + ;; + esac + done < <(git diff --unified=0 "$base_commit" "$head_commit" -- internal/db/driver_agent_revisions_gen.go) +} + is_ignored_driver_agent_source_file() { case "$1" in - *_test.go|frontend/*|internal/app/*|internal/db/driver_agent_revisions_gen.go) + *_test.go|frontend/*|internal/app/*) return 0 ;; esac @@ -469,6 +491,15 @@ declare -a forced_changed_drivers=() forced_driver_seen="|" for file in "${!changed_file_set[@]}"; do case "$file" in + internal/db/driver_agent_revisions_gen.go) + revision_delta="$(revision_file_changed_drivers)" + if [[ -z "$revision_delta" ]]; then + echo "检测到 driver-agent revision 文件存在无法归因的变更;保守构建全部 driver-agent:$file" >&2 + all_drivers_csv + exit 0 + fi + add_forced_drivers_from_tokens "$revision_delta" + ;; go.mod|go.sum|build-driver-agents.sh|tools/generate-driver-agent-revisions.sh) set +e shared_delta="$(shared_file_driver_delta "$file")" @@ -487,7 +518,9 @@ for file in "${!changed_file_set[@]}"; do exit 0 ;; tools/detect-changed-driver-agents.sh) - # This script only selects CI work; it is not embedded in driver-agent binaries. + echo "检测到 driver-agent 变更检测脚本更新;保守构建全部 driver-agent:$file" >&2 + all_drivers_csv + exit 0 ;; esac done diff --git a/tools/detect-changed-driver-agents.test.sh b/tools/detect-changed-driver-agents.test.sh new file mode 100755 index 0000000..4380a7e --- /dev/null +++ b/tools/detect-changed-driver-agents.test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${BASH_VERSINFO[0]:-0}" -lt 4 ]]; then + echo "skip: detect-changed-driver-agents.sh requires Bash 4+ for associative arrays; current bash is ${BASH_VERSION:-unknown}" + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-detect-driver-revisions.XXXXXX")" +tmpdir_script="" +cleanup() { + rm -rf "$tmpdir" + if [[ -n "$tmpdir_script" ]]; then + rm -rf "$tmpdir_script" + fi +} +trap cleanup EXIT + +git init -q "$tmpdir" +mkdir -p "$tmpdir/tools" +cp tools/detect-changed-driver-agents.sh "$tmpdir/tools/detect-changed-driver-agents.sh" +mkdir -p "$tmpdir/internal/db" +cat >"$tmpdir/internal/db/driver_agent_revisions_gen.go" <<'GOEOF' +package db + +func init() { + optionalDriverAgentRevisions = map[string]string{ + "mariadb": "src-old-mariadb", + "clickhouse": "src-old-clickhouse", + } +} +GOEOF + +( + cd "$tmpdir" + git add . + git -c user.name=GoNavi -c user.email=gonavi@example.test commit -q -m initial + base="$(git rev-parse HEAD)" + perl -0pi -e 's/src-old-clickhouse/src-new-clickhouse/' internal/db/driver_agent_revisions_gen.go + git add internal/db/driver_agent_revisions_gen.go + git -c user.name=GoNavi -c user.email=gonavi@example.test commit -q -m 'update clickhouse revision' + actual="$(bash ./tools/detect-changed-driver-agents.sh --base "$base" --head HEAD)" + if [[ "$actual" != "clickhouse" ]]; then + echo "expected clickhouse revision-only change to trigger clickhouse build, got: ${actual:-}" >&2 + exit 1 + fi +) + +tmpdir_script="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-detect-script-change.XXXXXX")" +git init -q "$tmpdir_script" +mkdir -p "$tmpdir_script/tools" +cp tools/detect-changed-driver-agents.sh "$tmpdir_script/tools/detect-changed-driver-agents.sh" +( + cd "$tmpdir_script" + git add . + git -c user.name=GoNavi -c user.email=gonavi@example.test commit -q -m initial + base="$(git rev-parse HEAD)" + printf '\n# test change\n' >> tools/detect-changed-driver-agents.sh + git add tools/detect-changed-driver-agents.sh + git -c user.name=GoNavi -c user.email=gonavi@example.test commit -q -m 'update detection script' + actual="$(bash ./tools/detect-changed-driver-agents.sh --base "$base" --head HEAD 2>/dev/null)" + if [[ "$actual" != *"mariadb"* || "$actual" != *"clickhouse"* || "$actual" != *"elasticsearch"* ]]; then + echo "expected detection script change to trigger all driver builds, got: ${actual:-}" >&2 + exit 1 + fi +) + +echo "detect-changed-driver-agents revision test passed"