🐛 fix(driver-manager): 修复驱动 revision 错配与安装卡住

- 修复 revision 生成文件变更未触发 driver-agent 构建的问题
- 检测脚本自身变更时保守触发全量 driver-agent 构建
- 调整 dev 构建驱动安装策略为发布包优先、源码构建兜底
- 为驱动总包下载和源码构建增加超时边界
- 为驱动管理安装流程增加前端看门狗并补充回归测试
This commit is contained in:
Syngnat
2026-06-05 08:34:38 +08:00
parent 2438899ff5
commit 53811969c5
6 changed files with 200 additions and 28 deletions

View File

@@ -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(<DriverManagerModal open onClose={vi.fn()} />);
});
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();
}
});
});

View File

@@ -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<typeof setTimeout> | 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<never>((_, 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]);

View File

@@ -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 {

View File

@@ -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"))

View File

@@ -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

View File

@@ -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:-<empty>}" >&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:-<empty>}" >&2
exit 1
fi
)
echo "detect-changed-driver-agents revision test passed"