Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8bb2bee95 | |||
| 37743f66c4 | |||
| b84cb2b07b | |||
| 136756134a | |||
| e85c216112 | |||
| 2315883b1f | |||
| 4bb0704d3e | |||
| cea3e2340f |
@@ -14,59 +14,16 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
|||||||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||||||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
||||||
|
|
||||||
/// 本进程内真正落盘持有项目写锁的线程登记表。
|
|
||||||
///
|
|
||||||
/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清
|
|
||||||
/// 两种完全不同的局面:
|
|
||||||
/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要
|
|
||||||
/// 白等一个等待预算再报“项目正在被其他写操作占用”;
|
|
||||||
/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、
|
|
||||||
/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。
|
|
||||||
///
|
|
||||||
/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory
|
|
||||||
/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销:
|
|
||||||
/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者),
|
|
||||||
/// 按线程注销会漏项,让后续的重入判断失真。
|
|
||||||
static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex<Vec<(PathBuf, std::thread::ThreadId)>> =
|
|
||||||
std::sync::Mutex::new(Vec::new());
|
|
||||||
|
|
||||||
fn project_write_lock_thread_owners(
|
|
||||||
) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> {
|
|
||||||
// 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成
|
|
||||||
// 整个进程再也写不了项目。
|
|
||||||
PROJECT_WRITE_LOCK_THREAD_OWNERS
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register_project_write_lock_thread_owner(path: &Path) {
|
|
||||||
let mut owners = project_write_lock_thread_owners();
|
|
||||||
if owners.iter().any(|(owner, _)| owner == path) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
owners.push((path.to_path_buf(), std::thread::current().id()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unregister_project_write_lock_thread_owner(path: &Path) {
|
|
||||||
project_write_lock_thread_owners().retain(|(owner, _)| owner != path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。
|
|
||||||
fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool {
|
|
||||||
let thread = std::thread::current().id();
|
|
||||||
project_write_lock_thread_owners()
|
|
||||||
.iter()
|
|
||||||
.any(|(owner, owner_thread)| owner == path && *owner_thread == thread)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ProjectWriteLock {
|
pub(crate) struct ProjectWriteLock {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
content: String,
|
content: String,
|
||||||
/// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条
|
/// In the free-form autonomous lane a single Runtime process may have
|
||||||
/// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种
|
/// several specialist actions in flight at once. A file lock is still
|
||||||
/// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有
|
/// useful across processes, but making same-process contenders fail turns
|
||||||
/// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。
|
/// ordinary parallel work into a dead run (and can deadlock nested tool
|
||||||
|
/// calls). Such a contender receives an in-process/advisory guard instead
|
||||||
|
/// of deleting the real holder's lock on drop.
|
||||||
bypassed_same_process: bool,
|
bypassed_same_process: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +47,6 @@ impl Drop for ProjectWriteLock {
|
|||||||
if self.bypassed_same_process {
|
if self.bypassed_same_process {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unregister_project_write_lock_thread_owner(&self.path);
|
|
||||||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
||||||
let _ = fs::remove_file(&self.path);
|
let _ = fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
@@ -859,7 +815,6 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
register_project_write_lock_thread_owner(&path);
|
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
@@ -905,15 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if project_write_lock_is_owned_by_current_process(&path)
|
if project_write_lock_is_owned_by_current_process(&path) {
|
||||||
&& (crate::agent::autonomous_game_build_root_run_active_at(root)
|
// A project lock is the client-use lock. Nested calls in
|
||||||
|| project_write_lock_reentered_by_current_thread(&path))
|
// the same client process must reuse that ownership instead
|
||||||
{
|
// of waiting on their own durable marker. Cross-process
|
||||||
// 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一
|
// contenders still take the normal retryable path.
|
||||||
// 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory
|
|
||||||
// guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走
|
|
||||||
// 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装
|
|
||||||
// 的串行化。
|
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
|
|||||||
@@ -5818,27 +5818,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("allow direct file write");
|
.expect("allow direct file write");
|
||||||
// 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须
|
let lock = acquire_project_write_lock(&root, "persistent-writer")
|
||||||
// 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。
|
.expect("acquire persistent project writer");
|
||||||
let holder_root = root.clone();
|
|
||||||
let (release_sender, release_receiver) = mpsc::channel::<()>();
|
|
||||||
let holder = std::thread::spawn(move || {
|
|
||||||
let lock = acquire_project_write_lock(&holder_root, "persistent-writer")
|
|
||||||
.expect("acquire persistent project writer");
|
|
||||||
let _ = release_receiver.recv();
|
|
||||||
drop(lock);
|
|
||||||
});
|
|
||||||
let lock_path = root.join(PROJECT_WRITE_LOCK_PATH);
|
|
||||||
for _ in 0..400 {
|
|
||||||
if lock_path.is_file() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
lock_path.is_file(),
|
|
||||||
"persistent writer must hold the project write lock"
|
|
||||||
);
|
|
||||||
|
|
||||||
let observation = execute_game_creator_agent_runtime_tool_action(
|
let observation = execute_game_creator_agent_runtime_tool_action(
|
||||||
&root,
|
&root,
|
||||||
@@ -5856,8 +5837,7 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = release_sender.send(());
|
drop(lock);
|
||||||
holder.join().expect("join persistent project writer");
|
|
||||||
assert_eq!(observation.status, "failed");
|
assert_eq!(observation.status, "failed");
|
||||||
assert!(!observation
|
assert!(!observation
|
||||||
.summary
|
.summary
|
||||||
|
|||||||
@@ -1214,28 +1214,8 @@ mod tests {
|
|||||||
.expect("resolve primary");
|
.expect("resolve primary");
|
||||||
fs::write(&primary, b"{broken").expect("corrupt primary");
|
fs::write(&primary, b"{broken").expect("corrupt primary");
|
||||||
|
|
||||||
// 持锁方必须是**另一条线程**:本用例验证的是“另一个写者持锁时恢复安装必须失败
|
let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save")
|
||||||
// 关闭”,同一条调用链自持锁属于重入复用,不再产生占用失败。
|
.expect("hold project write lock");
|
||||||
let holder_root = directory.path().to_path_buf();
|
|
||||||
let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>();
|
|
||||||
let holder = std::thread::spawn(move || {
|
|
||||||
let lock = acquire_project_write_lock(&holder_root, "test.concurrent-save")
|
|
||||||
.expect("hold project write lock");
|
|
||||||
let _ = release_receiver.recv();
|
|
||||||
drop(lock);
|
|
||||||
});
|
|
||||||
let lock_path = resolve_local_project_path(directory.path(), PROJECT_WRITE_LOCK_PATH)
|
|
||||||
.expect("resolve project write lock path");
|
|
||||||
for _ in 0..400 {
|
|
||||||
if lock_path.is_file() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
lock_path.is_file(),
|
|
||||||
"concurrent writer must hold the project write lock"
|
|
||||||
);
|
|
||||||
let error = load_ui_design_state_at(LoadUiDesignStateInput {
|
let error = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
expected_project_id: PROJECT_ID.to_string(),
|
expected_project_id: PROJECT_ID.to_string(),
|
||||||
@@ -1244,8 +1224,7 @@ mod tests {
|
|||||||
.expect_err("recovery must not install while another writer holds the lock");
|
.expect_err("recovery must not install while another writer holds the lock");
|
||||||
assert!(error.contains("项目正在被其他写操作占用"));
|
assert!(error.contains("项目正在被其他写操作占用"));
|
||||||
assert!(read_ui_design_document_path(&primary).is_err());
|
assert!(read_ui_design_document_path(&primary).is_err());
|
||||||
let _ = release_sender.send(());
|
drop(project_lock);
|
||||||
holder.join().expect("join concurrent writer");
|
|
||||||
|
|
||||||
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
|
|||||||
@@ -15,16 +15,14 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
## 修改顺序
|
## 修改顺序
|
||||||
|
|
||||||
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
||||||
2. 收窄复用判据:按 `pid` 放行会放过本进程其它线程的并行写,改为按“当前线程就是真实持锁线程”判定重入,并保住同进程跨线程的等待与终态占用。
|
2. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
||||||
3. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
3. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
||||||
4. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
4. 补齐同进程重入、跨进程占用、崩溃恢复和锁释放测试。
|
||||||
5. 补齐同进程重入、同进程跨线程争用、跨进程占用、崩溃恢复和锁释放测试。
|
|
||||||
|
|
||||||
## 验证命令
|
## 验证命令
|
||||||
|
|
||||||
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
|
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
|
||||||
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`
|
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock_reuses_same_process_owner_and_releases_on_drop --no-default-features`
|
||||||
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock --no-default-features`
|
|
||||||
- Runner owner 与 response stream 相关定向测试
|
- Runner owner 与 response stream 相关定向测试
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
@@ -33,5 +31,4 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
|
|
||||||
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
||||||
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
||||||
- 复用判据按线程判定:出现同进程跨线程重入的现场时先按 `*_locked` 入口处置,不要把判据退回按 `pid` 一律放行(那会放过并行写,见里程碑「边界」末条)。
|
|
||||||
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
||||||
|
|||||||
@@ -7,24 +7,22 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内**同一条写调用链(同一线程)的嵌套调用**复用既有项目锁,不因自身持锁进入等待。
|
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内的嵌套调用复用既有项目锁,不因自身持锁进入等待。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
||||||
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
||||||
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。**本进程其它线程的并发写入必须继续串行化**:按 `pid` 一律返回 advisory guard 会放过并行写,直接违反本边界(见验收标准第 2 条)。
|
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 同一线程(同一条写调用链)嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
- 同一进程内嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
||||||
- 本进程另一条线程持锁(模拟“另一个写通道/另一个客户端”的既有用例形态)时仍保持等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装不得被复用判据放过。
|
|
||||||
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
||||||
- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。
|
- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。
|
||||||
- 锁释放后下一客户端可重新取得锁。
|
- 锁释放后下一客户端可重新取得锁。
|
||||||
- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过;锁语义变更必须跑 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` 全量,定向用例覆盖不到 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence` 里的锁不变量。
|
- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。
|
||||||
|
|
||||||
## 未决事项
|
## 未决事项
|
||||||
|
|
||||||
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
||||||
- 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会走有界等待,预算耗尽时报“项目正在被其他写操作占用”。发现这类现场时按 2026-08-27 的既有处置改用 `*_locked` 入口复用已有 guard(`project-memory/shared-memory/pitfalls.md`「持锁调用链二次取锁」),不放宽整条锁的串行化语义。
|
|
||||||
|
|||||||
@@ -8637,10 +8637,3 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。
|
- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。
|
||||||
- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。
|
- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。
|
||||||
- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。
|
- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。
|
||||||
|
|
||||||
## 2026-09-14 项目写锁的同进程复用收窄为同线程重入
|
|
||||||
|
|
||||||
- 背景:`write_lock.rs` 的 advisory 复用判据曾放宽为「`.agent/project.lock` 的 `pid` 等于当前进程」,使本进程所有写通道都不再等待。`Project CI` 的 Rust 全量门禁因此出现 12 条失败:另一线程持锁时一致快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待,4 路并行直写撞项目 revision 侧车(`File exists (os error 17)`),8 线程并发 steer 拿到重复序号,`file.write` 锁失败脱敏与恢复安装的失败关闭变成成功。
|
|
||||||
- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。
|
|
||||||
- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。
|
|
||||||
- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
# 踩坑与排障记录
|
# 踩坑与排障记录
|
||||||
|
|
||||||
## 2026-09-14 项目写锁的同进程复用判据不能只看 pid
|
|
||||||
|
|
||||||
- **现象**:`master` 的 `Project CI / Native shell tests` 红在 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`,12 条用例失败(`2439 passed; 12 failed`)。断言分三类:① 另一线程持锁时快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待(`... must wait for the project consistency lock`);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 `File exists (os error 17)`,8 线程并发 steer 拿到 `[1, 1, 1, 1, 1, 1, 1, 2]`;③ 别的写通道持锁时 `file.write` 与恢复安装必须失败关闭,实测变成 `ok` / 不再报占用。
|
|
||||||
- **原因**:`project/write_lock.rs` 的 advisory 复用判据从「自主游戏构建流水线 + 本进程持锁」放宽成「本进程持锁」,而判据只比 `.agent/project.lock` JSON 里的 `pid`。`pid` 只能证明锁由本进程持有,分不清「同一条调用链再次取锁(必须放行,否则自己等自己)」和「本进程另一条写通道正在写(必须继续串行化)」;于是同进程其它线程的写通道也拿到 advisory guard。
|
|
||||||
- **处理**:复用判据收窄到**同线程重入**。新增 `PROJECT_WRITE_LOCK_THREAD_OWNERS`(按锁路径登记真实持锁线程)与 `project_write_lock_reentered_by_current_thread`:登记在 `create_new` 成功处,注销在 guard `Drop` 里并且**按路径**注销(guard 会被移到别的线程再 Drop,例如写入路径交给阻塞线程池的持有者)。只有当前线程就是该路径的持锁线程(或自主游戏构建流水线)才返回 advisory guard;本进程其余争用继续走有界等待与终态占用。
|
|
||||||
- **易错点**:① 用「同线程」近似重入后,靠**同线程自持锁 + 同线程调用**模拟「另一个写者」的用例会失去信号(`agent_runtime_file_write_lock_failure_redacts_project_path`、`recovery_install_respects_the_project_write_lock`):它们必须改成**在另一条线程持锁**,断言才有意义;② 不要用「同进程还有 guard 活着」当重入依据,那等于退回按 `pid` 放行;③ 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会等满预算并在耗尽时报占用,出现这类现场按 2026-08-27 的处置改用 `*_locked` 入口复用已有 guard,不要放宽判据。
|
|
||||||
- **验证**:本地定向 36 条(`--test-threads=1`,过滤 `_after_project_lock` / `bridge_write_file` / `project_write_lock` 等):CI 那 12 条里 9 条转绿(覆盖 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `external_generation_state`),3 条在本机被 Windows 临时目录 owner/DACL 挡在 setup(与本次改动无关,见 2026-09-13 条);`project_write_lock_reuses_same_process_owner_and_releases_on_drop`(同线程重入)继续通过。`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 全绿。
|
|
||||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`src/agent/runtime_actions/project_gates.rs`(有界等待预算)、`docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-14 项目客户端占用锁收敛」。
|
|
||||||
|
|
||||||
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
|
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
|
||||||
|
|
||||||
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
|
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
|
||||||
@@ -1176,8 +1167,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
|||||||
|
|
||||||
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。
|
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。
|
||||||
- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`,Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。
|
- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`,Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。
|
||||||
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` 和 `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
|
- 处理:Windows 本地 dev 脚本默认把两个 wrapper 设为空字符串;只有用户显式配置 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 时才处理 sccache。sccache 配置会先限时执行真实 `sccache rustc -vV` 探测,失败、超时或两个变量冲突时回退到直接 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
|
||||||
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
|
- 验证:`npm run test -- scripts/dev.test.ts -t "dev scheduler Rust build env"`;POSIX 显式配置 sccache 时日志应明确说明绕过并使用直接 rustc;再用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
|
||||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## Pingora 直连 80/443 不能只改 env
|
## Pingora 直连 80/443 不能只改 env
|
||||||
@@ -2657,7 +2648,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
|||||||
|
|
||||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
||||||
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
||||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 由 `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||||
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||||
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -1372,5 +1372,5 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
|||||||
|
|
||||||
## 2026-09-14 项目客户端占用锁收敛
|
## 2026-09-14 项目客户端占用锁收敛
|
||||||
|
|
||||||
项目锁职责收敛为“客户端占用项目”这一事实:跨进程竞争继续沿用现有占用、残留回收和权限分类。同进程复用的判据收窄到**同一条写调用链(同一线程)重入**——本线程已落盘持有该项目的 `.agent/project.lock` 时再次取锁,返回 advisory guard,不再等待自身持有的锁。本进程**其它线程**的写入通道仍走有界等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装都依赖这把锁把同进程的并发写入串行化,按 `pid` 一律放行会让它们静默竞态。自主游戏构建流水线沿用既有的并行专家动作豁免。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。
|
项目锁职责收敛为“客户端占用项目”这一事实:同一客户端进程内的嵌套项目写入调用复用已有项目锁并返回 advisory guard,不再等待自身持有的 `.agent/project.lock`;跨进程竞争继续沿用现有占用、残留回收和权限分类。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato
|
|||||||
|
|
||||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||||
|
|
||||||
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
|
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。
|
||||||
|
|
||||||
### 本地 Rust 构建缓存与磁盘上限
|
### 本地 Rust 构建缓存与磁盘上限
|
||||||
|
|
||||||
|
|||||||
@@ -104,4 +104,4 @@
|
|||||||
|
|
||||||
- 原生(真实 Runner/IPC)与真实 Provider 下的同一批时序未执行:本轮结论来自 deterministic surface 与 mock 故障注入。
|
- 原生(真实 Runner/IPC)与真实 Provider 下的同一批时序未执行:本轮结论来自 deterministic surface 与 mock 故障注入。
|
||||||
- `src-tauri/src/project/bootstrap.rs` 在 `npm install` 之前读取 `package-lock.json` 计算 `lockSha256`,安装后仍使用旧字节;疑似只影响审计准确性,未复现、未修改。
|
- `src-tauri/src/project/bootstrap.rs` 在 `npm install` 之前读取 `package-lock.json` 计算 `lockSha256`,安装后仍使用旧字节;疑似只影响审计准确性,未复现、未修改。
|
||||||
- 同 PID 下的写入 advisory guard(`write_lock.rs` 的 `bypassed_same_process`)是否会放过并行写:**已确认会**。只比 `pid` 的豁免让同进程其它线程的写通道也跳过 `.agent/project.lock`,`Project CI` 的 Rust 全量门禁因此红了 12 条(4 路并行直写撞项目 revision 侧车报 `File exists`、8 线程并发 steer 序号重复、一致快照读 / pending 复核 / 恢复安装不再等待、写锁失败不再失败关闭)。已把复用判据收窄为**同线程重入**:本进程其它线程继续走有界等待与终态占用,详见 `docs/project-memory/shared-memory/pitfalls.md`「项目写锁的同进程复用判据不能只看 pid」与 `docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md`。
|
- 同 PID 下的写入 advisory guard(`write_lock.rs` 的 `bypassed_same_process`)是否会放过并行写,尚未排除误报。
|
||||||
|
|||||||
+50
-13
@@ -146,9 +146,24 @@ function applyLocalFfmpegEnv(env, platform = process.platform) {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLocalDevRustcWrapperBypass() {
|
function resolveLocalDevRustcWrapperBypass(options = {}) {
|
||||||
// Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。
|
if (process.platform !== 'win32') {
|
||||||
return process.platform === 'win32' ? '' : '/usr/bin/env';
|
return '/usr/bin/env';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sccacheAvailable =
|
||||||
|
options.sccacheAvailable ??
|
||||||
|
(() => {
|
||||||
|
const result = spawnSync('sccache', ['rustc', '-vV'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
shell: true,
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: options.sccacheProbeTimeoutMs ?? 10_000,
|
||||||
|
});
|
||||||
|
return !result.error && result.status === 0;
|
||||||
|
})();
|
||||||
|
return sccacheAvailable ? 'sccache' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_NAMES = [
|
const SERVICE_NAMES = [
|
||||||
@@ -583,21 +598,43 @@ function buildLocalRustProcessEnv(env, options = {}) {
|
|||||||
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
||||||
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const customWrapper = wrappers.find(
|
const uniqueWrappers = [...new Set(wrappers)];
|
||||||
(wrapper) => !isSccacheRustcWrapper(wrapper),
|
if (uniqueWrappers.length > 1) {
|
||||||
);
|
mergedEnv.RUSTC_WRAPPER = '';
|
||||||
if (customWrapper) {
|
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = '';
|
||||||
mergedEnv.RUSTC_WRAPPER = customWrapper;
|
if (options.log !== false) {
|
||||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
|
console.warn(
|
||||||
|
'[dev:rust] RUSTC_WRAPPER 与 CARGO_BUILD_RUSTC_WRAPPER 配置冲突,回退到直接 rustc。',
|
||||||
|
);
|
||||||
|
}
|
||||||
return mergedEnv;
|
return mergedEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rustcWrapperBypass = resolveLocalDevRustcWrapperBypass();
|
const configuredWrapper = uniqueWrappers[0] ?? '';
|
||||||
mergedEnv.RUSTC_WRAPPER = rustcWrapperBypass;
|
if (configuredWrapper && !isSccacheRustcWrapper(configuredWrapper)) {
|
||||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapperBypass;
|
mergedEnv.RUSTC_WRAPPER = configuredWrapper;
|
||||||
|
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = configuredWrapper;
|
||||||
|
return mergedEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rustcWrapper = configuredWrapper
|
||||||
|
? resolveLocalDevRustcWrapperBypass(options)
|
||||||
|
: '';
|
||||||
|
mergedEnv.RUSTC_WRAPPER = rustcWrapper;
|
||||||
|
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapper;
|
||||||
if (options.log !== false) {
|
if (options.log !== false) {
|
||||||
|
const isPosixSccacheBypass =
|
||||||
|
configuredWrapper &&
|
||||||
|
isSccacheRustcWrapper(configuredWrapper) &&
|
||||||
|
process.platform !== 'win32';
|
||||||
console.warn(
|
console.warn(
|
||||||
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
|
isPosixSccacheBypass
|
||||||
|
? '[dev:rust] POSIX 本地 dev 构建绕过 sccache,使用直接 rustc。'
|
||||||
|
: rustcWrapper
|
||||||
|
? '[dev:rust] 本地 dev 构建启用 sccache wrapper。'
|
||||||
|
: configuredWrapper
|
||||||
|
? '[dev:rust] sccache wrapper 探测失败,回退到直接 rustc。'
|
||||||
|
: '[dev:rust] 未显式配置 Rust wrapper,使用直接 rustc。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return mergedEnv;
|
return mergedEnv;
|
||||||
|
|||||||
+94
-23
@@ -657,34 +657,55 @@ describe('dev scheduler local worker cleanup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('dev scheduler Rust build env', () => {
|
describe('dev scheduler Rust build env', () => {
|
||||||
test('local dev Rust env bypasses project sccache wrapper', () => {
|
test('Windows 下未显式配置 wrapper 时默认关闭 sccache', () => {
|
||||||
const env = buildLocalRustProcessEnv(
|
const originalPlatform = Object.getOwnPropertyDescriptor(
|
||||||
{
|
process,
|
||||||
RUSTC_WRAPPER: '/usr/bin/sccache',
|
'platform',
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
|
||||||
},
|
|
||||||
{ log: false },
|
|
||||||
);
|
);
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
configurable: true,
|
||||||
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
value: 'win32',
|
||||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
});
|
||||||
|
try {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{},
|
||||||
|
{ log: false, sccacheAvailable: true },
|
||||||
|
);
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
|
||||||
|
} finally {
|
||||||
|
if (originalPlatform)
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
test('POSIX 显式配置 sccache 时日志说明实际绕过', () => {
|
||||||
const env = buildLocalRustProcessEnv(
|
const originalPlatform = Object.getOwnPropertyDescriptor(
|
||||||
{
|
process,
|
||||||
RUSTC_WRAPPER: 'custom-wrapper',
|
'platform',
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
|
||||||
},
|
|
||||||
{ log: false },
|
|
||||||
);
|
);
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
Object.defineProperty(process, 'platform', {
|
||||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
configurable: true,
|
||||||
|
value: 'linux',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'sccache' },
|
||||||
|
{ log: true },
|
||||||
|
);
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
'[dev:rust] POSIX 本地 dev 构建绕过 sccache,使用直接 rustc。',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
if (originalPlatform)
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache', () => {
|
test('local dev Rust env enables available sccache wrapper', () => {
|
||||||
const originalPlatform = Object.getOwnPropertyDescriptor(
|
const originalPlatform = Object.getOwnPropertyDescriptor(
|
||||||
process,
|
process,
|
||||||
'platform',
|
'platform',
|
||||||
@@ -700,7 +721,48 @@ describe('dev scheduler Rust build env', () => {
|
|||||||
RUSTC_WRAPPER: 'sccache',
|
RUSTC_WRAPPER: 'sccache',
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||||
},
|
},
|
||||||
{ log: false },
|
{ log: false, sccacheAvailable: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('sccache');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
||||||
|
} finally {
|
||||||
|
if (originalPlatform) {
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rust wrapper 配置为同一个自定义 wrapper 时保持不变', () => {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{
|
||||||
|
RUSTC_WRAPPER: 'custom-wrapper',
|
||||||
|
CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper',
|
||||||
|
},
|
||||||
|
{ log: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows 下未安装 sccache 时回退到空 wrapper', () => {
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(
|
||||||
|
process,
|
||||||
|
'platform',
|
||||||
|
);
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{
|
||||||
|
RUSTC_WRAPPER: 'sccache',
|
||||||
|
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||||
|
},
|
||||||
|
{ log: false, sccacheAvailable: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(env.RUSTC_WRAPPER).toBe('');
|
expect(env.RUSTC_WRAPPER).toBe('');
|
||||||
@@ -711,6 +773,15 @@ describe('dev scheduler Rust build env', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Rust wrapper 配置冲突时回退到空 wrapper', () => {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper' },
|
||||||
|
{ log: false },
|
||||||
|
);
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dev scheduler stack state file', () => {
|
describe('dev scheduler stack state file', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user