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"