修复: shell 探测缓存测试顺序污染

PWSH_CACHE 从 OnceLock 改互斥可恢复形态,写缓存测试进时备份出时写回
涉缓存测试共持串行闸,消除全量跑时序性互踩(约一成概率触发的假失败)
生产读写语义不变:有缓存读缓存,无缓存探测后写,超时不写入
This commit is contained in:
lxy
2026-10-01 03:16:24 +08:00
parent 47a75a4d49
commit 73fe49b70c
2 changed files with 57 additions and 17 deletions
+49 -16
View File
@@ -38,7 +38,7 @@ impl Default for ShellType {
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 探测失败(未装 pwsh)回退 PS5。探测结果缓存于模块级 PWSH_CACHE(只探一次)。
//
// 【序约束 / 死缓存修复】Default 为同步签名,读模块级单源 PWSH_CACHE(由 probe_pwsh 写入)。
// 缓存有两个填充点(均走 probe_pwsh_blocking 单真相源,无漂移):
@@ -53,7 +53,7 @@ impl Default for ShellType {
// 非单源 + env_snapshot 另持 probe_command_success 独立探测。根本修:合并为模块级 PWSH_CACHE 单源,
// 写读同源;detect_shell 复用 probe_pwsh_blocking(单一探测实现)。
if cfg!(windows) {
if PWSH_CACHE.get().copied().unwrap_or(false) { ShellType::Pwsh } else { ShellType::PowerShell }
if cache_get().unwrap_or(false) { ShellType::Pwsh } else { ShellType::PowerShell }
} else {
ShellType::Sh
}
@@ -62,13 +62,34 @@ impl Default for ShellType {
/// pwsh(PowerShell 7)可用性的全局单源缓存。
///
/// 由异步 `probe_pwsh()`(execute 路径)或同步 `probe_pwsh_blocking()`(detect_shell 路径)填充,
/// 由异步 `probe_pwsh()`(execute 路径)或同步 `probe_pwsh_blocking()`(detect_shell 路径)探测,
/// 同步路径 `ShellType::default()` / `current_shell()` 读取——写读同源,
/// 杜绝历史上「两个 OnceLock 各填各的、Default 读的永空」死缓存 bug。
///
/// 语义:`get() == None` 表示尚未探测(首次启动 / Windows 未装 pwsh 也仅表示探测未跑或返 false);
/// `get() == Some(true)` 表示已探测且 pwsh 可用。
static PWSH_CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// 语义:`None` 表示尚未探测(首次启动 / Windows 未装 pwsh 也仅表示探测未跑或返 false);
/// `Some(true)` 表示已探测且 pwsh 可用。
///
/// 用 `Mutex<Option<bool>>` 而非 OnceLock:OnceLock 写入后进程级不可清,写缓存的测试
/// 与依赖缓存一致性的测试(env_snapshot 漂移测试)全量并行跑时互踩。Mutex 化后测试可
/// 备份/复位隔离(配 PWSH_CACHE_TEST_LOCK 串行闸);生产读写语义不变,有缓存读缓存、
/// 无缓存探测后写。
static PWSH_CACHE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
/// 读缓存当前值(毒化容错:持锁测试 panic 后其余测试仍可继续,行为不变)。
fn cache_get() -> Option<bool> {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner())
}
/// 写缓存当前值(探测产物写入 / 测试复位共用,覆盖写)。
fn cache_set(v: Option<bool>) {
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = v;
}
/// 测试串行闸:缓存为进程级单源,写缓存的测试与依赖缓存一致性的测试并行互踩,
/// 凡改写缓存或依赖缓存/EnvSnapshot 一致性的测试须持锁(仅测试隔离用,非生产路径)。
/// 对齐 path_auth.rs 的 BLACKLIST_BYPASS_TEST_LOCK 模式;env_snapshot 漂移测试持同闸。
#[cfg(test)]
pub(crate) static PWSH_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 同步探测 pwsh(PowerShell 7)是否可用(std::Command::status),成功返回 true。
///
@@ -94,7 +115,7 @@ pub(crate) fn probe_pwsh_blocking() -> bool {
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,3s 超时,只探一次)。
/// 探测 pwsh(PowerShell 7)是否可用(模块级缓存,3s 超时,只探一次)。
///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
@@ -110,16 +131,16 @@ pub(crate) fn probe_pwsh_blocking() -> bool {
/// - 仅「正常完成(Ok(Ok(_)))」时 set 缓存(此时结果是可信的探测产物)。
/// 对齐 env_snapshot.rs:64-80 的「spawn_blocking + timeout + 超时不 set」模式。
async fn probe_pwsh() -> bool {
if let Some(cached) = PWSH_CACHE.get() {
return *cached;
if let Some(cached) = cache_get() {
return cached;
}
match tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::task::spawn_blocking(probe_pwsh_blocking),
).await {
Ok(Ok(result)) => {
// 正常完成:best-effort set(多任务竞态以先到者为准,均等价)
let _ = PWSH_CACHE.set(result);
// 正常完成:写入缓存(多任务竞态覆盖写,各写者探测值等价)
cache_set(Some(result));
result
}
Ok(Err(join_err)) => {
@@ -155,18 +176,25 @@ mod tests {
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
///
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
/// 本测试通过手动 set 模块级 PWSH_CACHE 后断言 default() 返回 Pwsh,锁定「写读同源」不变量。
/// 本测试经 probe_pwsh 写模块级 PWSH_CACHE 后断言 default() 与探测值一致,锁定「写读同源」不变量。
///
/// 隔离:持串行闸,进时备份、断言前复位。OnceLock 时代写入后全进程不可清,
/// 会污染并行的 env_snapshot 漂移测试(其快照先以空缓存创建,断言即漂移)。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_cache_shared_with_default() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 探测一次填充缓存(无论机器是否装 pwsh,只要写读同源即应一致)。
// 注:正常完成路径才会 set 缓存;本机 pwsh 探测不超时/不 panic,故 cached 应与 probed 一致。
// 注:正常完成路径才会写缓存;本机 pwsh 探测不超时/不 panic,故 cached 应与 probed 一致。
let probed = probe_pwsh().await;
let cached = PWSH_CACHE.get().copied();
// 探测完成后缓存必已填充(同步路径由此读到)
assert_eq!(cached, Some(probed));
let cached = cache_get();
// Default 必须读到与 probe 一致的判定:Pwsh ↔ true,PowerShell ↔ false
let default_shell = ShellType::default();
// 复位放在断言前:中途 panic 不残留污染态(对齐 path_auth.rs 先复位再断言)
cache_set(backup);
// 探测完成后缓存必已填充(同步路径由此读到)
assert_eq!(cached, Some(probed));
match probed {
true => assert_eq!(default_shell, ShellType::Pwsh),
false => assert_eq!(default_shell, ShellType::PowerShell),
@@ -179,14 +207,19 @@ mod tests {
/// status() 永不返回 → spawn_blocking 线程永不返回 → probe_pwsh().await 永久挂 →
/// execute()/execute_streaming() 卡死 → run_agentic_loop 死锁(与 env_snapshot::detect 同源 bug)。
/// 本测试外包 10s timeout(远大于 3s 内部超时),无论探测成败都应在 10s 内返回,锁定「不挂死」不变量。
///
/// 隔离:持串行闸,探测在缓存为空时会写入缓存,备份/复位防污染并行测试。
#[cfg(windows)]
#[tokio::test]
async fn probe_pwsh_completes_within_timeout() {
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backup = cache_get();
// 10s >> probe_pwsh 内部 3s 超时;若 10s 仍未返回 → 探测挂死,不变量被破坏。
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
probe_pwsh(),
).await;
cache_set(backup);
assert!(result.is_ok(), "probe_pwsh 必须在 10s 内返回(内部 3s 超时已兜底),不应挂死");
}
}