fix(settings): serialize tool lifecycle writes and isolate Windows scripts (#7725)

Run each Windows tool lifecycle script from its own temp directory so concurrent calls can no longer overwrite or delete each other's .bat file.

Add a backend ToolLifecycleCoordinator: a global execution lock serializes all install/update writes (pnpm/bun share one global manifest, and concurrent 'add -g' silently drops one side's entry), and a per-tool try-lock rejects duplicate submissions even after the About page remounts. Locks move into the blocking task so a cancelled caller cannot release them while the installer is still running.

The About page now locks per tool instead of globally, queues multi-install confirmation plans, ignores double clicks on the confirm dialog, and shows the full error text in a tooltip.
This commit is contained in:
funkpopo authored and GitHub committed 2026-09-30 10:01:58 +08:00
1 parent 74f4614340
commit 482f57b335
4 files changed
+905 -185

No files matched your search

+224 -12
View File
@@ -8,6 +8,7 @@ use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
@@ -177,6 +178,63 @@ pub async fn get_tool_versions(
Ok(results)
}
// 不同工具仍可能共用 pnpm/npm 的全局目录。先保守地串行化所有安装写入,
// 并在排队前锁定工具,防止页面重挂或另一 IPC 调用重复提交同一工具。
struct ToolLifecycleCoordinator {
tools: HashMap<&'static str, Arc<tokio::sync::Mutex<()>>>,
execution: Arc<tokio::sync::Mutex<()>>,
}
impl Default for ToolLifecycleCoordinator {
fn default() -> Self {
Self {
tools: VALID_TOOLS
.iter()
.map(|&tool| (tool, Arc::new(tokio::sync::Mutex::new(()))))
.collect(),
execution: Arc::new(tokio::sync::Mutex::new(())),
}
}
}
impl ToolLifecycleCoordinator {
async fn run<F>(&self, tools: Vec<&'static str>, operation: F) -> Result<(), String>
where
F: FnOnce(&[&str]) -> Result<(), String> + Send + 'static,
{
let tool_guards = tools
.iter()
.map(|tool| {
self.tools
.get(tool)
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?
.clone()
.try_lock_owned()
.map_err(|_| {
format!(
"{} already has an installation or update in progress",
tool_display_name(tool)
)
})
})
.collect::<Result<Vec<_>, _>>()?;
let execution_guard = self.execution.clone().lock_owned().await;
// 必须把锁移进 blocking 任务:即使等待它的 IPC future 被取消,
// 子进程仍会继续运行,直到它真正退出前都不能允许下一次写入。
tokio::task::spawn_blocking(move || {
let _tool_guards = tool_guards;
let _execution_guard = execution_guard;
operation(&tools)
})
.await
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
}
}
static TOOL_LIFECYCLE: Lazy<ToolLifecycleCoordinator> =
Lazy::new(ToolLifecycleCoordinator::default);
#[tauri::command]
pub async fn run_tool_lifecycle_action(
tools: Vec<String>,
@@ -194,15 +252,14 @@ pub async fn run_tool_lifecycle_action(
ToolLifecycleAction::Update => "tool_update",
};
// build 阶段含锚定探测(对每个工具跑 `--version` 定位命令行实际命中那处),
// 与执行一并放进 blocking 线程,避免阻塞 async runtime。
tokio::task::spawn_blocking(move || {
let command_line =
build_tool_lifecycle_command(&requested, action, wsl_shell_by_tool.as_ref())?;
run_tool_lifecycle_silently(&command_line, label)
})
.await
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
// 排队结束后再探测安装目标,与执行一起持锁,避免读到另一升级的中间状态。
TOOL_LIFECYCLE
.run(requested, move |tools| {
let command_line =
build_tool_lifecycle_command(tools, action, wsl_shell_by_tool.as_ref())?;
run_tool_lifecycle_silently(&command_line, label)
})
.await
}
/// 静默执行工具安装/更新脚本:直接捕获子进程输出并阻塞到命令真正结束,
@@ -234,8 +291,12 @@ fn run_tool_lifecycle_silently(command_line: &str, label: &str) -> Result<(), St
use std::os::windows::process::CommandExt;
use std::process::Command;
let bat_file =
std::env::temp_dir().join(format!("cc_switch_{}_{}.bat", label, std::process::id()));
// 每次调用使用独立目录,避免同进程并发升级时覆盖或删除另一工具的脚本。
let script_dir = tempfile::Builder::new()
.prefix("cc_switch_lifecycle_")
.tempdir()
.map_err(|e| format!("创建批处理目录失败: {e}"))?;
let bat_file = script_dir.path().join(format!("{label}.bat"));
std::fs::write(&bat_file, command_line).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let output = Command::new("cmd")
@@ -243,7 +304,6 @@ fn run_tool_lifecycle_silently(command_line: &str, label: &str) -> Result<(), St
.arg(&bat_file)
.creation_flags(CREATE_NO_WINDOW)
.output();
let _ = std::fs::remove_file(&bat_file);
finish_lifecycle_output(&output.map_err(|e| format!("启动安装进程失败: {e}"))?)
}
@@ -5251,6 +5311,158 @@ mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[tokio::test]
async fn lifecycle_coordinator_serializes_writes_and_rejects_duplicate_tools() {
let coordinator = Arc::new(ToolLifecycleCoordinator::default());
let output_dir = tempfile::tempdir().unwrap();
let output = output_dir.path().join("installed.txt");
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
let first = {
let coordinator = coordinator.clone();
let output = output.clone();
tokio::spawn(async move {
coordinator
.run(vec!["codex"], move |_| {
started_tx.send(()).unwrap();
finish_rx.recv().unwrap();
std::fs::write(output, "codex").unwrap();
Ok(())
})
.await
})
};
started_rx.await.unwrap();
// 独立调用者(例如重挂后的页面)不能重复启动正在执行的工具。
assert!(coordinator
.run(vec!["codex"], |_| panic!("duplicate must not execute"))
.await
.unwrap_err()
.contains("in progress"));
// 批次部分取锁失败时,已取得的其他工具锁也必须释放。
assert!(coordinator
.run(vec!["claude", "codex"], |_| panic!(
"batch must not execute"
))
.await
.is_err());
let second_output = output.clone();
let second = coordinator.run(vec!["claude"], move |_| {
assert_eq!(std::fs::read_to_string(&second_output).unwrap(), "codex");
std::fs::write(second_output, "codex,claude").unwrap();
Ok(())
});
tokio::pin!(second);
assert!(futures::poll!(second.as_mut()).is_pending());
assert!(!output.exists(), "first write has not finished yet");
assert!(coordinator
.run(vec!["claude"], |_| panic!(
"queued duplicate must not execute"
))
.await
.unwrap_err()
.contains("in progress"));
finish_tx.send(()).unwrap();
first.await.unwrap().unwrap();
second.await.unwrap();
assert_eq!(std::fs::read_to_string(output).unwrap(), "codex,claude");
}
#[tokio::test]
async fn lifecycle_coordinator_keeps_running_locks_when_caller_is_cancelled() {
let coordinator = Arc::new(ToolLifecycleCoordinator::default());
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
let first = {
let coordinator = coordinator.clone();
tokio::spawn(async move {
coordinator
.run(vec!["codex"], move |_| {
started_tx.send(()).unwrap();
finish_rx.recv().unwrap();
Ok(())
})
.await
})
};
started_rx.await.unwrap();
first.abort();
assert!(first.await.unwrap_err().is_cancelled());
assert!(coordinator
.run(vec!["codex"], |_| panic!(
"running duplicate must not execute"
))
.await
.is_err());
{
let cancelled = coordinator.run(vec!["claude"], |_| {
panic!("cancelled queued operation must not execute")
});
tokio::pin!(cancelled);
assert!(futures::poll!(cancelled.as_mut()).is_pending());
}
// 尚未开始的排队请求取消后可以重试,但仍须等待正在运行的子任务退出。
let retry = coordinator.run(vec!["claude"], |_| Ok(()));
tokio::pin!(retry);
assert!(futures::poll!(retry.as_mut()).is_pending());
finish_tx.send(()).unwrap();
retry.await.unwrap();
coordinator.run(vec!["codex"], |_| Ok(())).await.unwrap();
}
#[tokio::test]
async fn lifecycle_coordinator_releases_locks_after_error_or_panic() {
let coordinator = ToolLifecycleCoordinator::default();
assert_eq!(
coordinator
.run(vec!["claude"], |_| Err("installer failed".to_string()))
.await,
Err("installer failed".to_string())
);
coordinator.run(vec!["claude"], |_| Ok(())).await.unwrap();
assert!(coordinator
.run(vec!["claude"], |_| panic!("installer panicked"))
.await
.unwrap_err()
.contains("tool lifecycle task join error"));
coordinator.run(vec!["claude"], |_| Ok(())).await.unwrap();
}
#[cfg(target_os = "windows")]
#[test]
fn concurrent_lifecycle_runs_use_independent_scripts() {
let output_dir = tempfile::tempdir().unwrap();
let outputs = [
output_dir.path().join("first.txt"),
output_dir.path().join("second.txt"),
];
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
for output in &outputs {
let barrier = &barrier;
scope.spawn(move || {
let command = format!("@echo off\r\n>\"{}\" echo %~f0\r\n", output.display());
barrier.wait();
run_tool_lifecycle_silently(&command, "tool_update").unwrap();
});
}
});
let script_paths =
outputs.map(|output| PathBuf::from(std::fs::read_to_string(output).unwrap().trim()));
assert_ne!(script_paths[0], script_paths[1]);
for script_path in script_paths {
assert!(!script_path.exists(), "temporary script must be cleaned up");
assert!(
!script_path.parent().unwrap().exists(),
"temporary directory must be cleaned up"
);
}
}
/// 探测 helper 正常路径:spawn(含 pre_exec setsid)能启动、输出能捕获。
/// `/bin/echo --version` 在 macOS/Linux 均即刻成功退出。
#[cfg(not(target_os = "windows"))]
+171 -169
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Download,
Copy,
@@ -16,6 +16,13 @@ import {
Stethoscope,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Portal as TooltipPortal } from "@radix-ui/react-tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
@@ -73,6 +80,12 @@ const TOOL_NAMES = [
type ToolName = (typeof TOOL_NAMES)[number];
type ToolLifecycleAction = "install" | "update";
interface PendingUpgrade {
toolNames: ToolName[];
plans: ToolInstallationReport[];
fromBatchEntry: boolean;
}
type WslShellPreference = {
wslShell?: string | null;
wslShellFlag?: string | null;
@@ -253,9 +266,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const [isLoadingTools, setIsLoadingTools] = useState(
() => toolVersionsCache === null,
);
const [toolActions, setToolActions] = useState<
Partial<Record<ToolName, ToolLifecycleAction>>
>({});
const [batchAction, setBatchAction] = useState<ToolLifecycleAction | null>(
null,
);
@@ -274,25 +284,19 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
Partial<Record<ToolName, ToolInstallation[]>>
>({});
const [isDiagnosingAll, setIsDiagnosingAll] = useState(false);
// 升级前探测到「多处安装需确认」时暂存:toolNames=本次要升级的全部工具,
// plans=其中需要确认的(≥2 处)那些。用户确认后对 toolNames 整体执行升级。
// fromBatchEntry=是否来自「全部升级」入口:确认后执行期需据此补 batchAction
// (executeRun 按数量判 isBatch,「全部升级(1)」会漏掉顶部按钮的 spinner)。
const [pendingUpgrade, setPendingUpgrade] = useState<{
toolNames: ToolName[];
plans: ToolInstallationReport[];
fromBatchEntry: boolean;
} | null>(null);
// 升级 preflight(probe 阶段)的 in-flight 工具集合。
// probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize),
// 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / 官方 installer,写冲突)。
// 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。
// 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight,
// 精确反映到各自卡片按钮的 disabled。
const [preflightTools, setPreflightTools] = useState<Set<ToolName>>(
() => new Set(),
// 并发探测可能同时要求确认,按到达顺序排队,避免覆盖尚未处理的升级计划。
const pendingUpgrades = useRef<PendingUpgrade[]>([]);
const [pendingUpgrade, setPendingUpgrade] = useState<PendingUpgrade | null>(
null,
);
// 每个工具独立锁定,覆盖预检、等待确认、执行与版本刷新。
// ref 同步加锁,避免 React 提交 state 前的重复点击启动同一工具两次。
const busyToolsRef = useRef(new Set<ToolName>());
const [busyTools, setBusyTools] = useState<Set<ToolName>>(() => new Set());
const releaseTools = useCallback((toolNames: ToolName[]) => {
toolNames.forEach((name) => busyToolsRef.current.delete(name));
setBusyTools(new Set(busyToolsRef.current));
}, []);
const toolVersionByName = useMemo(() => {
return new Map(toolVersions.map((tool) => [tool.name, tool]));
@@ -302,9 +306,13 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
() =>
TOOL_NAMES.filter((toolName) => {
const tool = toolVersionByName.get(toolName);
return isUpdateAvailable(tool?.version, tool?.latest_version);
return (
!busyTools.has(toolName) &&
!loadingTools[toolName] &&
isUpdateAvailable(tool?.version, tool?.latest_version)
);
}),
[toolVersionByName],
[toolVersionByName, busyTools, loadingTools],
);
const refreshToolVersions = useCallback(
@@ -596,16 +604,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [t]);
// 实际执行安装/升级的串行循环(已通过任何必要的确认后才调用)。
// 已通过必要的确认后,各工具独立提交、刷新和解锁;安装写入由后端串行调度。
const executeRun = useCallback(
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
const isBatch = toolNames.length > 1;
if (isBatch) {
setBatchAction(action);
}
// 逐工具串行执行:每个工具独立成败、独立刷新版本,一个失败不会连坐
// 后续工具(后端把整批拼成单脚本 + set -e,会在首个失败处中止整批)。
// 每个工具独立调用后端,一个失败不会中断其它工具。
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
const failures: {
@@ -616,86 +620,81 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}[] = [];
let succeeded = 0;
for (const toolName of toolNames) {
setToolActions((prev) => ({ ...prev, [toolName]: action }));
try {
const previousTool = toolVersionByName.get(toolName);
const previousVersion = previousTool?.version ?? null;
const previousLatestVersion = previousTool?.latest_version ?? null;
await Promise.all(
toolNames.map(async (toolName) => {
try {
const previousTool = toolVersionByName.get(toolName);
const previousVersion = previousTool?.version ?? null;
const previousLatestVersion = previousTool?.latest_version ?? null;
await settingsApi.runToolLifecycleAction(
[toolName],
action,
wslShellByTool,
);
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
const refreshed = await refreshToolVersions(
[toolName],
wslShellByTool,
);
const tool = refreshed.find((t) => t.name === toolName);
if (tool?.version) {
const latestVersion = tool.latest_version ?? previousLatestVersion;
const versionUnchangedAfterUpdate =
action === "update" &&
Boolean(previousVersion) &&
tool.version === previousVersion &&
isUpdateAvailable(tool.version, latestVersion);
await settingsApi.runToolLifecycleAction(
[toolName],
action,
wslShellByTool,
);
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
const refreshed = await refreshToolVersions(
[toolName],
wslShellByTool,
);
const tool = refreshed.find((t) => t.name === toolName);
if (tool?.version) {
const latestVersion =
tool.latest_version ?? previousLatestVersion;
const versionUnchangedAfterUpdate =
action === "update" &&
Boolean(previousVersion) &&
tool.version === previousVersion &&
isUpdateAvailable(tool.version, latestVersion);
if (versionUnchangedAfterUpdate) {
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
if (versionUnchangedAfterUpdate) {
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
failures.push({
toolName,
detail: t("settings.toolActionVersionUnchanged", {
version: tool.version,
latest: latestVersion ?? t("common.unknown"),
}),
soft: true,
kind: "versionUnchanged",
});
void diagnoseToolSilently(toolName);
} else {
succeeded += 1;
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
if (action === "update") {
void diagnoseToolSilently(toolName);
}
}
} else {
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
const detail =
tool?.error?.trim() || t("settings.toolNotRunnable");
failures.push({
toolName,
detail: t("settings.toolActionVersionUnchanged", {
version: tool.version,
latest: latestVersion ?? t("common.unknown"),
}),
detail,
soft: true,
kind: "versionUnchanged",
kind: "notRunnable",
});
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
void diagnoseToolSilently(toolName);
} else {
succeeded += 1;
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
if (action === "update") {
void diagnoseToolSilently(toolName);
}
}
} else {
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
failures.push({
toolName,
detail,
soft: true,
kind: "notRunnable",
});
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
void diagnoseToolSilently(toolName);
} catch (error) {
console.error(
`[AboutSection] Failed to run tool action for ${toolName}`,
error,
);
const detail = extractErrorMessage(error) || String(error);
failures.push({ toolName, detail, soft: false });
} finally {
releaseTools([toolName]);
}
} catch (error) {
console.error(
`[AboutSection] Failed to run tool action for ${toolName}`,
error,
);
const detail = extractErrorMessage(error) || String(error);
failures.push({ toolName, detail, soft: false });
} finally {
setToolActions((prev) => {
const next = { ...prev };
delete next[toolName];
return next;
});
}
}
if (isBatch) {
setBatchAction(null);
}
}),
);
const actionLabel =
action === "install"
@@ -766,49 +765,33 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
toolVersionByName,
refreshToolVersions,
diagnoseToolSilently,
releaseTools,
],
);
// 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁;
// 这层锁覆盖三个否则会漏掉的窗口:
// ① update 的 probeToolInstallations 阶段(1-3 秒跨进程,executeRun 之前);
// ② executeRun 内部 setToolActions 落到 React commit 前的几个 microtask;
// ③ install 直接进 executeRun 的同一段 microtask 窗口。
// 早退检查避免同一工具在 toolActions / preflight 已登记时被重复入栈——批量场景里
// 只要有一个工具被锁,整批不开新一轮,因为后端 set -e 串行的语义假设是「一次性
// 单脚本」,跨两次 IPC 并发会破坏它。
// 单独升级与全部升级共用按工具加锁的入口,自动跳过已在处理的工具。
const handleRunToolAction = useCallback(
async (
toolNames: ToolName[],
requestedTools: ToolName[],
action: ToolLifecycleAction,
options?: { fromBatchEntry?: boolean },
) => {
const toolNames = requestedTools.filter(
(name) => !busyToolsRef.current.has(name),
);
if (toolNames.length === 0) return;
if (
toolNames.some(
(name) => preflightTools.has(name) || toolActions[name] !== undefined,
)
) {
return;
}
// 入栈 preflight,按钮立刻 disabled。new Set(prev) 是不可变更新(直接 mutate
// 原 Set 会让 React 复用引用、跳过 re-render);finally 块负责异常路径解锁。
setPreflightTools((prev) => {
const next = new Set(prev);
toolNames.forEach((name) => next.add(name));
return next;
});
// 「全部升级」入口在 probe 阶段就点亮 spinner:batchAction 若只由 executeRun
// 置位,预检的数秒(探测超时兜底时更久)里按钮只 disabled 不转圈,观感如
// "点了没反应"。按入口来源而非 toolNames.length 判定——「全部升级(1)」
// 传入长度 1,按数量判会漏。executeRun 内部会再置位/清除,重复 set 无害;
// 弹确认框路径由 finally 清掉,对话框期间不转圈。
toolNames.forEach((name) => busyToolsRef.current.add(name));
setBusyTools(new Set(busyToolsRef.current));
// 锁移交给 executeRun 或确认队列后,由其负责释放;入口只释放未移交的工具。
let toolsToRelease = toolNames;
// 全部升级即使只剩一个工具,也在预检和执行期间显示进度。
const fromBatchEntry = options?.fromBatchEntry ?? false;
if (fromBatchEntry) {
setBatchAction(action);
}
try {
if (action === "install") {
toolsToRelease = [];
await executeRun(toolNames, action);
return;
}
@@ -818,6 +801,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
} catch (error) {
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。
console.error("[AboutSection] probeToolInstallations failed", error);
toolsToRelease = [];
await executeRun(toolNames, action);
return;
}
@@ -843,6 +827,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
(name) => !unmanaged.some((r) => r.tool === name),
);
if (runnableTools.length === 0) return;
toolsToRelease = toolNames.filter(
(name) => !runnableTools.includes(name),
);
const needConfirm = reports.filter(
(r) => r.needs_confirmation && !r.unmanaged,
);
@@ -850,54 +837,50 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
await executeRun(runnableTools, action);
return;
}
setPendingUpgrade({
pendingUpgrades.current.push({
toolNames: runnableTools,
plans: needConfirm,
fromBatchEntry,
});
setPendingUpgrade(pendingUpgrades.current[0]);
} finally {
if (fromBatchEntry) {
setBatchAction(null);
}
setPreflightTools((prev) => {
const next = new Set(prev);
toolNames.forEach((name) => next.delete(name));
return next;
});
releaseTools(toolsToRelease);
}
},
[executeRun, preflightTools, toolActions, t],
[executeRun, releaseTools, t],
);
const handleConfirmUpgrade = useCallback(() => {
if (pendingUpgrade) {
const { toolNames, fromBatchEntry } = pendingUpgrade;
// executeRun 按数量判 isBatch,「全部升级(1)」确认后执行期会漏置 batchAction,
// 由入口来源在这里补上;对 >1 的场景 executeRun 会重复置位/清除,无害。
if (fromBatchEntry) {
setBatchAction("update");
}
void executeRun(toolNames, "update").finally(() => {
if (fromBatchEntry) {
setBatchAction(null);
}
});
if (!pendingUpgrade || pendingUpgrades.current[0] !== pendingUpgrade)
return;
pendingUpgrades.current.shift();
setPendingUpgrade(pendingUpgrades.current[0] ?? null);
const { toolNames, fromBatchEntry } = pendingUpgrade;
if (fromBatchEntry) {
setBatchAction("update");
}
setPendingUpgrade(null);
void executeRun(toolNames, "update").finally(() => {
if (fromBatchEntry) {
setBatchAction(null);
}
});
}, [pendingUpgrade, executeRun]);
const handleCancelUpgrade = useCallback(() => setPendingUpgrade(null), []);
const handleCancelUpgrade = useCallback(() => {
if (!pendingUpgrade || pendingUpgrades.current[0] !== pendingUpgrade)
return;
pendingUpgrades.current.shift();
setPendingUpgrade(pendingUpgrades.current[0] ?? null);
releaseTools(pendingUpgrade.toolNames);
}, [pendingUpgrade, releaseTools]);
const displayVersion = version ?? t("common.unknown");
// 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮,
// 避免并发触发多个 npm/pip 全局写入造成冲突。
// preflightTools 覆盖升级前的 probe 阶段——那段在 executeRun 之前、toolActions
// 还没置位,如果不算进 busy 会留出 1-3 秒的并发触发窗口。
const isAnyBusy =
Boolean(batchAction) ||
Object.keys(toolActions).length > 0 ||
preflightTools.size > 0;
// 全量刷新和诊断仍等待升级结束;各工具的操作只受自身忙碌状态影响。
const isAnyBusy = busyTools.size > 0;
return (
<motion.section
@@ -1099,7 +1082,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
})
}
disabled={
isLoadingTools || isAnyBusy || updatableToolNames.length === 0
isLoadingTools ||
Boolean(batchAction) ||
updatableToolNames.length === 0
}
>
{batchAction === "update" ? (
@@ -1143,7 +1128,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
: isOutdated
? "update"
: null;
const runningAction = toolActions[toolName];
const isToolBusy = busyTools.has(toolName);
const title = tool?.version || tool?.error || t("common.unknown");
const conflicts = toolDiagnostics[toolName];
@@ -1218,9 +1203,27 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</span>
</div>
{!isToolVersionLoading && !tool?.version && tool?.error && (
<div className="truncate text-[11px] text-muted-foreground">
{tool.error}
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
tabIndex={0}
className="cursor-help truncate text-[11px] text-muted-foreground"
>
{tool.error}
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="bottom"
align="start"
className="max-h-[min(24rem,var(--radix-tooltip-content-available-height))] max-w-[min(36rem,calc(100vw_-_2rem))] select-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
>
{tool.error}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
)}
</div>
@@ -1229,7 +1232,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<Select
value={wslShellByTool[toolName]?.wslShell || "auto"}
onValueChange={(v) => handleToolShellChange(toolName, v)}
disabled={isToolVersionLoading || isAnyBusy}
disabled={isToolVersionLoading || isToolBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
@@ -1248,7 +1251,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
onValueChange={(v) =>
handleToolShellFlagChange(toolName, v)
}
disabled={isToolVersionLoading || isAnyBusy}
disabled={isToolVersionLoading || isToolBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
@@ -1300,11 +1303,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
variant={action === "install" ? "outline" : "default"}
className="h-7 gap-1.5 text-xs"
onClick={() => handleRunToolAction([toolName], action)}
disabled={isToolVersionLoading || isAnyBusy}
disabled={isToolVersionLoading || isToolBusy}
>
{/* preflight(升级前冲突探测)阶段也转圈:此时 toolActions
尚未置位,只 disabled 不转圈会让探测的数秒像"点了没反应"。 */}
{runningAction || preflightTools.has(toolName) ? (
{/* 预检、等待确认与执行期间都显示该工具的进度。 */}
{isToolBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : action === "install" ? (
<Download className="h-3.5 w-3.5" />
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
@@ -33,6 +34,12 @@ export function ToolUpgradeConfirmDialog({
onCancel,
}: ToolUpgradeConfirmDialogProps) {
const { t } = useTranslation();
const titleRef = useRef<HTMLHeadingElement>(null);
// 队首切换后移开操作按钮的焦点,按住 Enter/Space 不能继续处理下一项。
useEffect(() => {
if (isOpen) titleRef.current?.focus();
}, [isOpen, plans]);
return (
<Dialog
@@ -41,9 +48,20 @@ export function ToolUpgradeConfirmDialog({
if (!open) onCancel();
}}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogContent
className="max-w-md"
zIndex="alert"
onOpenAutoFocus={(event) => {
event.preventDefault();
titleRef.current?.focus();
}}
>
<DialogHeader className="space-y-2 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
<DialogTitle
ref={titleRef}
tabIndex={-1}
className="flex items-center gap-2 text-base font-semibold"
>
<AlertTriangle className="h-5 w-5 text-yellow-500" />
{t("settings.toolUpgradeConfirmTitle")}
</DialogTitle>
@@ -89,10 +107,20 @@ export function ToolUpgradeConfirmDialog({
</div>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
<Button
variant="outline"
onClick={(event) => {
if (event.detail <= 1) onCancel();
}}
>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm}>
<Button
onClick={(event) => {
// 第二次 click 可能已经面对下一项计划,不能把双击视作两次授权。
if (event.detail <= 1) onConfirm();
}}
>
{t("settings.toolUpgradeConfirmBtn")}
</Button>
</DialogFooter>
+478
View File
@@ -0,0 +1,478 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolInstallationReport } from "@/lib/api/settings";
const mocks = vi.hoisted(() => ({
getToolVersions: vi.fn(),
probeToolInstallations: vi.fn(),
runToolLifecycleAction: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
}));
vi.mock("@/lib/api", () => ({ settingsApi: mocks }));
vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "3.20.4" }));
vi.mock("@/contexts/UpdateContext", () => ({
useUpdate: () => ({ hasUpdate: false, isChecking: false }),
}));
vi.mock("@/config/appConfig", () => ({ APP_ICON_MAP: {} }));
vi.mock("sonner", () => ({ toast: mocks }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function report(
tool: string,
overrides: Partial<ToolInstallationReport> = {},
): ToolInstallationReport {
return {
tool,
installs: [],
is_conflict: false,
needs_confirmation: false,
command: `${tool} update`,
anchored: true,
unmanaged: false,
...overrides,
};
}
const upgraded = new Set<string>();
const outdated = new Set<string>();
const missing = new Set<string>();
function card(name: string) {
return within(screen.getByText(name).closest(".rounded-xl") as HTMLElement);
}
function updateButton(name: string) {
return card(name).getByRole("button", { name: "settings.toolUpdate" });
}
async function renderAbout() {
// AboutSection caches version results at module scope between mounts.
const { AboutSection } = await import("@/components/settings/AboutSection");
render(<AboutSection isPortable={false} />);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "common.refresh" }),
).toBeEnabled(),
);
}
describe("AboutSection concurrent CLI upgrades", () => {
beforeEach(() => {
vi.resetModules();
upgraded.clear();
outdated.clear();
missing.clear();
outdated.add("claude").add("codex").add("gemini");
mocks.getToolVersions
.mockReset()
.mockImplementation(async (tools: string[]) =>
tools.map((name) => ({
name,
version: missing.has(name)
? null
: upgraded.has(name) || !outdated.has(name)
? "2.0.0"
: "1.0.0",
latest_version: "2.0.0",
error: null,
installed_but_broken: false,
env_type: "windows",
wsl_distro: null,
})),
);
mocks.probeToolInstallations
.mockReset()
.mockImplementation(async (tools: string[]) =>
tools.map((tool) => report(tool)),
);
mocks.runToolLifecycleAction
.mockReset()
.mockImplementation(async ([tool]: string[]) => {
upgraded.add(tool);
missing.delete(tool);
});
});
it("lets different tools preflight and submit together while blocking duplicate clicks", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const claudeRun = deferred<void>();
const codexRun = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await (tool === "claude" ? claudeRun.promise : codexRun.promise);
upgraded.add(tool);
},
);
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Claude Code"));
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Codex")).toBeEnabled();
fireEvent.click(updateButton("Codex"));
expect(mocks.probeToolInstallations.mock.calls).toEqual([
[["claude"]],
[["codex"]],
]);
await act(async () => {
claudeProbe.resolve([report("claude")]);
codexProbe.resolve([report("codex")]);
});
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(2);
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Codex")).toBeDisabled();
expect(updateButton("Gemini CLI")).toBeEnabled();
await act(async () => codexRun.resolve());
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument();
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
expect(
card("Claude Code").getByText("settings.toolReady"),
).toBeInTheDocument();
expect(mocks.success).toHaveBeenCalledTimes(2);
});
it("submits the remaining tools while a single upgrade is already running", async () => {
const runs = new Map(
["claude", "codex", "gemini"].map((name) => [name, deferred<void>()]),
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await runs.get(tool)!.promise;
upgraded.add(tool);
},
);
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
const updateAll = screen.getByRole("button", {
name: "settings.updateAllTools",
});
expect(updateAll).toBeEnabled();
fireEvent.click(updateAll);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
expect(mocks.probeToolInstallations).toHaveBeenNthCalledWith(2, [
"codex",
"gemini",
]);
expect(
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
).toEqual([["claude"], ["codex"], ["gemini"]]);
await act(async () => {
runs.get("gemini")!.resolve();
runs.get("codex")!.resolve();
});
expect(
card("Gemini CLI").getByText("settings.toolReady"),
).toBeInTheDocument();
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument();
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => runs.get("claude")!.resolve());
});
it("releases a failed tool for retry without unlocking another running tool", async () => {
const firstRun = deferred<void>();
const retryRun = deferred<void>();
const codexRun = deferred<void>();
const geminiRun = deferred<void>();
let claudeAttempts = 0;
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
const run =
tool === "claude"
? ++claudeAttempts === 1
? firstRun
: retryRun
: tool === "codex"
? codexRun
: geminiRun;
await run.promise;
upgraded.add(tool);
},
);
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
await renderAbout();
fireEvent.click(
screen.getByRole("button", { name: "settings.updateAllTools" }),
);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
await act(async () => firstRun.reject(new Error("upgrade failed")));
expect(updateButton("Claude Code")).toBeEnabled();
expect(updateButton("Codex")).toBeDisabled();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(4),
);
await act(async () => {
codexRun.resolve();
geminiRun.resolve();
});
expect(updateButton("Claude Code")).toBeDisabled();
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolActionPartial",
expect.objectContaining({ description: "Claude Code: upgrade failed" }),
);
await act(async () => retryRun.resolve());
expect(
card("Claude Code").getByText("settings.toolReady"),
).toBeInTheDocument();
errorLog.mockRestore();
});
it("queues concurrent confirmation requests and releases only the cancelled tool", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const claudeRun = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await claudeRun.promise;
upgraded.add(tool);
},
);
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
expect(
within(screen.getByRole("dialog")).getByText("Claude Code"),
).toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).queryByText("Codex"),
).not.toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: "settings.toolUpgradeConfirmBtn" }),
);
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["claude"],
"update",
{},
);
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(updateButton("Codex")).toBeEnabled();
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
});
it.each(["double click", "held Enter", "held Space"])(
"does not confirm the next queued plan with a %s",
async (gesture) => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const run = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(() => run.promise);
const user = userEvent.setup();
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
const confirm = screen.getByRole("button", {
name: "settings.toolUpgradeConfirmBtn",
});
if (gesture === "double click") {
await user.dblClick(confirm);
} else {
confirm.focus();
await user.keyboard(
gesture === "held Enter" ? "{Enter>3/}" : "[Space>3/]",
);
}
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
[["claude"], "update", {}],
]);
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
if (gesture !== "double click") {
expect(
screen.getByRole("heading", {
name: "settings.toolUpgradeConfirmTitle",
}),
).toHaveFocus();
}
// 第二项仍可通过一次新的、明确的操作正常确认。
await user.click(confirm);
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
[["claude"], "update", {}],
[["codex"], "update", {}],
]);
await act(async () => run.resolve());
},
);
it("does not cancel the next queued plan with a double click", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
const user = userEvent.setup();
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
await user.dblClick(screen.getByRole("button", { name: "common.cancel" }));
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "common.cancel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(updateButton("Claude Code")).toBeEnabled();
expect(updateButton("Codex")).toBeEnabled();
});
it("does not block another installation submission while an upgrade is running", async () => {
const claudeRun = deferred<void>();
missing.add("codex");
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
if (tool === "claude") await claudeRun.promise;
upgraded.add(tool);
missing.delete(tool);
},
);
await renderAbout();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
const install = card("Codex").getByRole("button", {
name: "settings.toolInstall",
});
expect(install).toBeEnabled();
fireEvent.click(install);
await waitFor(() =>
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument(),
);
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["codex"],
"install",
{},
);
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
});
it("keeps a tool locked until its version refresh finishes", async () => {
const refreshed =
deferred<Awaited<ReturnType<typeof mocks.getToolVersions>>>();
await renderAbout();
mocks.getToolVersions.mockImplementationOnce(() => refreshed.promise);
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(
card("Claude Code").getAllByText("common.loading").length,
).toBeGreaterThan(0),
);
expect(card("Claude Code").queryByRole("button")).not.toBeInTheDocument();
expect(updateButton("Codex")).toBeEnabled();
fireEvent.click(
screen.getByRole("button", { name: "settings.updateAllTools" }),
);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
expect(
mocks.runToolLifecycleAction.mock.calls.filter(([tools]) =>
tools.includes("claude"),
),
).toHaveLength(1);
await act(async () =>
refreshed.resolve([
{
name: "claude",
version: "2.0.0",
latest_version: "2.0.0",
error: null,
installed_but_broken: false,
env_type: "windows",
wsl_distro: null,
},
]),
);
expect(
card("Claude Code").getByText("settings.toolReady"),
).toBeInTheDocument();
});
it("releases skipped unmanaged tools and runs the remaining batch", async () => {
mocks.probeToolInstallations.mockImplementation(async (tools: string[]) =>
tools.map((tool) => report(tool, { unmanaged: tool === "claude" })),
);
await renderAbout();
fireEvent.click(
screen.getByRole("button", { name: "settings.updateAllTools" }),
);
await waitFor(() =>
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument(),
);
expect(
card("Gemini CLI").getByText("settings.toolReady"),
).toBeInTheDocument();
expect(updateButton("Claude Code")).toBeEnabled();
expect(
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
).toEqual([["codex"], ["gemini"]]);
expect(
screen.getByRole("button", { name: "common.refresh" }),
).toBeEnabled();
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolUpgradeUnmanagedTitle",
expect.anything(),
);
});
});