From 64b0d2f76659ff773666059f3cf51ee0b2b8a498 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 20:20:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=A4=96=E9=83=A8=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=BD=93=E3=80=81=E6=B3=A8=E5=85=A5=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E4=B8=8E=E9=94=81=E8=AF=8A=E6=96=AD=E7=9A=84=E4=B8=8D?= =?UTF-8?q?=E8=AE=BE=E7=95=8C=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收敛外部错误体、注入提示词与锁诊断的不设界行为(PR #316 review) - resource_editor.rs:2476 [bug · medium] 已被修复:`error_details` 改为 `error.details` 优先、顶层 `details` 兜底,与 docstring 声明的取值顺序一致;新增「两种形状同时出现」用例钉住优先级。 - resource_editor.rs:2468 [performance · low] 已被修复:4xx 错误体改走 `bytes_stream` 有界读取(64KiB 上限,超限即放弃取原因),不再用 `response.text()` 无上限缓冲外部正文。 - resource_editor.rs:2514-2525 [maintainability · low] 已被修复:provider / assetKind / mediaType / code 四个追加字段各自按 80 字符截断(`MAX_EDITOR_ERROR_DETAIL_CHARS`),最终文案长度不再随外部正文增长;新增超长字段整串比对用例。 - direct_codex_references.rs:175-182 [performance · medium] 已被修复:`resourceIds` 超过 32 条直接按模块失败关闭口径拒绝,并在同一 id 的重复项上做去重,注入提示词的「关联素材 ID」行长度与 manifest 扫描次数都有界;新增去重 + 超限用例。 - direct_runtime.rs:1795-1797 [maintainability · low] 已被修复:`direct_project_history_contention_failure` 收窄为只认追加锁超时标记(项目写锁争用已在更早的专用分支判掉,是死代码),恢复提示文案相应去掉「或项目锁」;新增用例钉住两条判据互不重叠、各自命中正确提示。 - conversation.rs:984-989 [maintainability · medium] 已被修复:`DIRECT_PROJECT_HISTORY_RECORD_TYPE` 提升为 `pub(crate)`,读取侧 `is_direct_project_history_row` 引用同一常量,`project.jsonl` 的信封契约改为编译期共享。 - agent_db.rs:3570-3574 [other · medium] 已被修复:`lock_with_attempts` 的进程内锁改为有界 `try_lock` 轮询(窗口与调用方一致,2000/200 次 × 5ms),同进程读路径不再被写者的 10s 跨进程等待拖住;顺序仍是「进程内锁 → 跨进程锁」,无 ABBA;新增用例验证短窗口 <5s 失败且释放后立即可重取。 - agent_db.rs:3668-3669 [maintainability · low] 已被修复:新增与锁同级的旁路诊断文件(普通共享写入),诊断优先读它、读不到再退回锁文件,Windows 上 zero-share 独占锁文件时也能报出持锁方;新增「取锁写出旁路文件」「锁文件不可读时退到旁路」两条用例。仅存 pid/用途/时间戳,不参与判活、回收或抢占。 - agent_db.rs:3827-3828 [maintainability · low] 已被修复:仅当修复错误真的符合提权口径(`windows_acl_error_may_need_elevation`)时才追加「自动提权修复未完成」,否则原样透传底层错误,不再重复报同一个错误并谎称尝试过提权。 - main.rs:29-31 [maintainability · low] 判定为不适用/不改:实测删掉那四个 import 会产生 18 处 E0425(`direct_tool_bridge` / `canvas_generation` / `assets` / `manifest` / `resource_editor` / `resource_dependency_graph` / `recovery_tests` 等经 `use super::*` 从 crate 根复用它们),而保留它们并不产生 `unused_imports` 警告(cargo check --all-targets 无一条指向这几行);已按原样恢复 main.rs,工作树中该文件无改动。 --- .../src/agent/direct_codex_references.rs | 56 +++++++++- .../src/agent/direct_project_history.rs | 4 +- .../src-tauri/src/agent/direct_runtime.rs | 47 +++++++- .../src-tauri/src/project/agent_db.rs | 104 +++++++++++++++--- .../src/project/agent_db/security_tests.rs | 85 ++++++++++++++ .../src-tauri/src/project/conversation.rs | 7 +- .../src-tauri/src/project/resource_editor.rs | 87 +++++++++++++-- 7 files changed, 358 insertions(+), 32 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs index f7d290391..f1b78b1ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs @@ -172,13 +172,26 @@ fn render_runtime_region_reference_line( let width = sanitize_reference_dimension(reference.width); let height = sanitize_reference_dimension(reference.height); + // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, + // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 + // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 + // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" + )); + } let mut related_resource_ids = Vec::new(); for resource_id in &reference.resource_ids { let resource_id = validate_resource_reference_id(resource_id)?; if !manifest.assets.iter().any(|asset| asset.id == resource_id) { return Err("运行画面引用的素材已变化,请重新点选".to_string()); } - related_resource_ids.push(resource_id); + // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 + // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 + if !related_resource_ids.contains(&resource_id) { + related_resource_ids.push(resource_id); + } } let mut parts = vec![format!("名称:{label}")]; @@ -325,4 +338,45 @@ mod tests { assert!(!section.contains("onclick")); assert!(!section.contains("secret")); } + + #[test] + fn runtime_region_dedupes_and_bounds_related_resource_ids() { + let project = fixture_project(); + // 同一个 id 重复出现只应产生一条关联。 + let duplicated: DirectCodexTurnReference = serde_json::from_str( + r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#, + ) + .expect("reference json"); + let section = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&duplicated), + ) + .expect("render") + .expect("section"); + assert!( + section.contains("关联素材 ID:asset-hero\n") + || section.trim_end().ends_with("关联素材 ID:asset-hero"), + "{section}" + ); + assert!( + !section.contains("asset-hero,"), + "重复 id 不得在注入提示词里重复出现:{section}" + ); + + // 超出上限直接失败关闭:不能按对方给的长度注入提示词。 + let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1) + .map(|_| "\"asset-hero\"".to_string()) + .collect::>() + .join(","); + let oversized: DirectCodexTurnReference = serde_json::from_str(&format!( + r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"# + )) + .expect("reference json"); + let error = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&oversized), + ) + .expect_err("oversized resource id list must fail closed"); + assert!(error.contains("最多关联"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 7761e6c05..a546fd671 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -13,7 +13,9 @@ use std::path::{Path, PathBuf}; const DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED: usize = 0; const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; -const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, +/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 +pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; /// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 /// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 8252b6c6a..38208af77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1793,7 +1793,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; } if direct_project_history_contention_failure(error) { - return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; + return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; } match stage { DirectCodexFailureStage::ArtPreparation => { @@ -1880,15 +1880,18 @@ fn direct_project_history_shape_failure(error: &str) -> bool { .any(|marker| error.contains(marker)) } -/// 跨进程锁 / 项目锁争用。 +/// 追加写的**跨进程追加锁**超时。 /// /// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 /// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 /// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。 -/// 判据只认两个常量,不各自复制中文。 +/// +/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`) +/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在 +/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句 +/// 与真实原因不符的描述有机会出现。 fn direct_project_history_contention_failure(error: &str) -> bool { error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) - || error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) } fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { @@ -4625,6 +4628,42 @@ mod tests { assert!(direct_codex_failure_is_retryable(io_error)); } + /// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示, + /// 追加锁超时才落到"历史未能落盘"。两条判据不得重叠。 + #[test] + fn direct_project_history_contention_hint_is_append_lock_only() { + let append_lock_timeout = format!( + "获取DirectProject 历史追加写{}", + crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER + ); + assert!(direct_project_history_contention_failure( + &append_lock_timeout + )); + assert_eq!( + direct_codex_failure_recovery_hint( + DirectCodexFailureStage::CodeGeneration, + &append_lock_timeout + ), + "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + ); + + let write_lock_contention = format!( + "{}C:/project", + crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX + ); + assert!( + !direct_project_history_contention_failure(&write_lock_contention), + "项目写锁争用不得再落进历史争用判据" + ); + assert_eq!( + direct_codex_failure_recovery_hint( + DirectCodexFailureStage::CodeGeneration, + &write_lock_contention + ), + "当前项目仍有写入正在结束,请稍后再次发送该需求" + ); + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 911f74d4b..bcd6d99d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -3572,10 +3572,7 @@ impl ProjectAppendLock { error_label: &str, max_attempts: usize, ) -> Result, String> { - let process_guard = self - .process_lock - .lock() - .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?; + let process_guard = self.try_lock_process(error_label, max_attempts)?; let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?; Ok(ProjectAppendGuard { @@ -3583,6 +3580,39 @@ impl ProjectAppendLock { _os_lock: os_lock, }) } + + /// 进程内锁也按**有界**等待取,窗口与调用方自己的窗口同长。 + /// + /// 这里曾经是无上限的 `lock()`:写者拿着进程内锁把 10s 的跨进程等待窗口走完,同一个进程里的 + /// `lock_short` 读者就一直卡在 `process_lock.lock()` 上 —— "短窗口让读路径保持可响应"只在 + /// 跨进程成立,同进程内并不成立。 + /// + /// 顺序不变:进程内锁**永远先于**跨进程锁取,且从不在持跨进程锁时回头取进程内锁, + /// 因此不存在 ABBA。拿不到时复用 `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`,调用方既有的 + /// "争用可重试"判据照样成立。 + fn try_lock_process( + &self, + error_label: &str, + max_attempts: usize, + ) -> Result, String> { + let max_attempts = max_attempts.max(1); + for attempt in 0..max_attempts { + match self.process_lock.try_lock() { + Ok(guard) => return Ok(guard), + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(format!("获取{error_label}进程内锁失败:锁已损坏")); + } + Err(std::sync::TryLockError::WouldBlock) => { + if attempt + 1 < max_attempts { + thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL); + } + } + } + } + Err(format!( + "获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:同一个客户端进程内还有一次追加写未结束(进程内锁等待已达上限)" + )) + } } pub(crate) fn project_append_lock_for(path: &Path) -> Result { @@ -3647,7 +3677,7 @@ fn acquire_project_append_os_lock( let max_attempts = max_attempts.max(1); for attempt in 0..max_attempts { if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? { - refresh_project_append_lock_diagnostic(&mut file, error_label); + refresh_project_append_lock_diagnostic(&mut file, path, error_label); return Ok(file); } if attempt + 1 < max_attempts { @@ -3666,10 +3696,21 @@ fn acquire_project_append_os_lock( /// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是 /// 一句"检查运行时配置",那既不是现象也不是动作。 fn project_append_lock_holder_diagnostic(path: &Path) -> String { + // Windows 上持锁方把锁文件本身按 zero-share 打开(`try_open_project_append_os_lock` 里的 + // `share_mode(0)`),别的进程连"读"都拿不到它,所以先读与锁同目录、按普通共享方式写出的 + // 旁路诊断文件。旁路读不到再退回锁文件本身:Unix 一直是这么读的,而且老版本客户端持锁时 + // 也只有锁文件里有线索。两个来源都读不到才算"身份不可读"。 + if let Ok(content) = fs::read_to_string(project_append_lock_holder_record_path(path)) { + return describe_project_append_lock_holder(&content); + } let Ok(content) = fs::read_to_string(path) else { return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string(); }; - let Ok(record) = serde_json::from_str::(&content) else { + describe_project_append_lock_holder(&content) +} + +fn describe_project_append_lock_holder(content: &str) -> String { + let Ok(record) = serde_json::from_str::(content) else { return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string(); }; let pid = record.get("pid").and_then(serde_json::Value::as_u64); @@ -3687,23 +3728,32 @@ fn project_append_lock_holder_diagnostic(path: &Path) -> String { } } -/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写。 +/// 旁路诊断文件:与锁同级、普通共享写入,专门给"锁文件本身读不到"的平台用。 +/// +/// 它是**只读诊断**,不参与判活 / 回收 / 抢占:拿它永远拿不到锁,锁所有权始终是那个 +/// zero-share 句柄。写入失败也不影响取锁结果(见 [`refresh_project_append_lock_diagnostic`])。 +fn project_append_lock_holder_record_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("append.lock"); + path.with_file_name(format!("{file_name}.holder.json")) +} + +/// 把"谁在持这把锁"写进锁文件与旁路诊断文件,供事后排障;同一进程重复取同一把锁时不重复写。 /// /// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与 /// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md /// 2026-09-09「项目写锁残留回收与启动诊断」)。 -fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) { +fn refresh_project_append_lock_diagnostic(file: &mut File, path: &Path, error_label: &str) { let pid = std::process::id(); let mut current = String::new(); - if file.seek(SeekFrom::Start(0)).is_ok() + let lock_file_already_records_this_process = file.seek(SeekFrom::Start(0)).is_ok() && std::io::Read::read_to_string(file, &mut current).is_ok() && serde_json::from_str::(¤t) .ok() .and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64)) - == Some(u64::from(pid)) - { - return; - } + == Some(u64::from(pid)); let Ok(serialized) = serde_json::to_string(&serde_json::json!({ "acquiredAt": unix_timestamp(), "label": error_label, @@ -3714,11 +3764,19 @@ fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) { })) else { return; }; - if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() { - return; + if !lock_file_already_records_this_process { + if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() { + return; + } + let _ = file.write_all(serialized.as_bytes()); + let _ = file.flush(); } - let _ = file.write_all(serialized.as_bytes()); - let _ = file.flush(); + // 旁路文件每次取锁都刷新(不跟着上面的"本进程已写过"短路):上一次可能就是写它失败的那次。 + // 失败静默:它只是排障线索,不能影响取锁结果。 + let _ = fs::write( + project_append_lock_holder_record_path(path), + serialized.as_bytes(), + ); } /// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。 @@ -3825,7 +3883,17 @@ fn release_project_append_os_lock_then_repair_acl( ) -> Result<(), String> { drop(held_lock); crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true) - .map_err(|repair_error| format!("{strict_error};自动提权修复未完成:{repair_error}")) + .map_err(|repair_error| { + // 只有"可能靠提权修好"的错误才配得上这句后缀。非提权类失败(锁文件在打开与严格校验 + // 之间被删掉、ACL 报错不在提权口径内)时,自动修复没有做任何事就原样返回了同一个 + // 错误,再贴一句"自动提权修复未完成"等于把同一条错误报两遍,还把现场指向一个 + // 根本没发生过的动作。 + if crate::config::windows_acl_error_may_need_elevation(&repair_error) { + format!("{strict_error};自动提权修复未完成:{repair_error}") + } else { + strict_error.to_string() + } + }) } #[cfg(windows)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 9a7239e06..43fe2df3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -3197,6 +3197,91 @@ fn append_lock_holder_diagnostic_reports_the_recorded_pid() { fs::remove_dir_all(&root).ok(); } +/// 取锁必须同时写出与锁同级的旁路诊断文件。 +/// +/// Windows 上持锁方以 zero-share 打开锁文件本身,别的进程连读都读不到,"谁在持锁"就只能靠 +/// 这个普通共享的旁路文件回答——那正是这套 UAC / 锁争用改动要落地的平台。 +#[test] +fn append_lock_acquisition_writes_the_holder_record_sidecar() { + let (root, target) = append_lock_test_target("append-lock-holder-sidecar"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + drop( + lock.lock("DirectProject 历史追加写") + .expect("acquire append lock"), + ); + + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + let sidecar = project_append_lock_holder_record_path(&lock_path); + let raw = fs::read_to_string(&sidecar).expect("read holder record sidecar"); + let record: serde_json::Value = + serde_json::from_str(&raw).expect("parse holder record sidecar"); + assert_eq!( + record["pid"], + serde_json::json!(u64::from(std::process::id())) + ); + assert_eq!( + record["label"], + serde_json::json!("DirectProject 历史追加写") + ); + assert!(record["acquiredAt"].is_number(), "{raw}"); + + fs::remove_dir_all(&root).ok(); +} + +/// 锁文件读不到时,诊断必须退回旁路文件,而不是直接判"身份不可读"。 +#[test] +fn append_lock_holder_diagnostic_falls_back_to_the_sidecar() { + let (root, target) = append_lock_test_target("append-lock-holder-sidecar-fallback"); + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir"); + fs::write( + project_append_lock_holder_record_path(&lock_path), + "{\"acquiredAt\":1,\"label\":\"旁路用途\",\"pid\":4242,\"processStartedAt\":7}", + ) + .expect("seed holder record sidecar"); + // 锁文件此刻不存在:旧实现只会报"身份不可读"。 + assert!(!lock_path.exists()); + + let diagnostic = project_append_lock_holder_diagnostic(&lock_path); + assert!(diagnostic.contains("pid=4242"), "{diagnostic}"); + assert!(diagnostic.contains("旁路用途"), "{diagnostic}"); + + fs::remove_dir_all(&root).ok(); +} + +/// 同进程内的读路径不能被写者的进程内锁拖住:短窗口必须有界地失败,而不是等写者走完 +/// 完整的跨进程等待窗口。 +#[test] +fn append_lock_short_wait_is_bounded_while_a_same_process_writer_holds_the_lock() { + let (root, target) = append_lock_test_target("append-lock-process-lock-bound"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + let writer = lock + .lock("DirectProject 历史追加写") + .expect("acquire append lock"); + + let started = std::time::Instant::now(); + let error = match lock.lock_short("DirectProject 历史追加读") { + Ok(_guard) => panic!("进程内锁被占满时短窗口必须失败而不是无限等待"), + Err(error) => error, + }; + let elapsed = started.elapsed(); + assert!( + error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER), + "{error}" + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "短窗口必须留在 1s 级,实际等待 {elapsed:?}" + ); + + drop(writer); + drop( + lock.lock_short("DirectProject 历史追加读") + .expect("reacquire after writer releases"), + ); + fs::remove_dir_all(&root).ok(); +} + /// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**—— /// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。 #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 2d98aa1b4..a2cc7952a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -981,9 +981,14 @@ fn read_persisted_local_conversation_records_unlocked( /// 只认 DirectProject 那一种明确枚举的行信封:`type=response_item` 且带 `payload`。 /// 其余任何形状都不算“对方的行”,仍由上方按坏行失败关闭。 +/// +/// 类型串引用写入侧的 `DIRECT_PROJECT_HISTORY_RECORD_TYPE`,不复制字面量:两边读写的 +/// 是同一份 `project.jsonl`,一旦只在写入侧改名,这里就会把每一行 DirectProject 记录 +/// 都判成坏行并以 `解析对话记录失败` 失败关闭,共享文件的读取整条断掉。 fn is_direct_project_history_row(line: &str) -> bool { serde_json::from_str::(line).is_ok_and(|parsed| { - parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item") + parsed.get("type").and_then(serde_json::Value::as_str) + == Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE) && parsed.get("payload").is_some() }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 160026329..b1f292976 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1,4 +1,5 @@ use super::*; +use futures::StreamExt; use reqwest::multipart::{Form, Part}; use std::collections::BTreeMap; use std::path::PathBuf; @@ -2460,20 +2461,39 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { /// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。 /// /// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、 -/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都 +/// `{details:{message}}` 或顶层 `{message}`。**`error.details.message` 优先**:平台把所有 4xx 都 /// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在 /// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message` /// 会让用户永远看到「请求参数不合法」。 async fn editor_api_rejection_reason(response: reqwest::Response) -> Option { - let body = response.text().await.ok()?; + // 错误体来自外部编辑器端点,不能无上限地缓冲:有界的读法把"上游返回一个超大 4xx 正文" + // 变成"读不到可读原因",而不是让 Tauri 后端按对方给的长度分配。 + const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024; + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.ok()?; + if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { + return None; + } + body.extend_from_slice(&chunk); + } + let body = String::from_utf8(body).ok()?; editor_api_rejection_reason_from_body(body.as_str()) } +/// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 +/// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。 +const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80; + fn editor_api_rejection_reason_from_body(body: &str) -> Option { let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?; let error = value.get("error"); let details = value.get("details"); - let error_details = details.or_else(|| error.and_then(|error| error.get("details"))); + // 先 `error.details`、再顶层 `details`:与下面注释声明的优先级一致。 + // 反过来写会让同时带两种形状的响应体永远命中笼统的顶层 `details.message`, + // 真正可操作的原因(`error.details.message`)永远看不到。 + let error_details = error.and_then(|error| error.get("details")).or(details); // 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本 // → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。 let candidate = error_details @@ -2512,16 +2532,20 @@ fn editor_api_rejection_reason_from_body(body: &str) -> Option { .filter(|code| !code.is_empty() && *code != "BAD_REQUEST"); let mut reason: String = candidate.chars().take(200).collect(); if let Some(provider) = detail_text("provider") { - reason.push_str(format!("|provider {provider}").as_str()); + reason.push_str("|provider "); + reason.extend(provider.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(asset_kind) = detail_text("assetKind") { - reason.push_str(format!("|素材类型 {asset_kind}").as_str()); + reason.push_str("|素材类型 "); + reason.extend(asset_kind.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(media_type) = detail_text("mediaType") { - reason.push_str(format!("|媒体类型 {media_type}").as_str()); + reason.push_str("|媒体类型 "); + reason.extend(media_type.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(code) = code { - reason.push_str(format!("|错误码 {code}").as_str()); + reason.push_str("|错误码 "); + reason.extend(code.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } Some(reason) } @@ -5612,6 +5636,55 @@ mod tests { assert_eq!(reason, "长".repeat(200)); } + /// 两种形状同时出现时 `error.details.message` 必须赢;每个透出字段都要单独有界。 + /// + /// 平台自身目前只发 `error.details`,所以这条是"代码与文档契约一致"的护栏: + /// 一旦取值顺序被写反,同时带顶层 `details` 的响应体就只剩笼统兜底文案。 + #[test] + fn editor_api_rejection_reason_prefers_nested_error_details_and_caps_every_field() { + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { "details": { "message": "嵌套里的具体原因" } }, + "details": { "message": "顶层的通用文案" }, + "message": "更外层文案", + }) + .to_string() + .as_str(), + ), + Some("嵌套里的具体原因".to_string()), + "error.details.message 必须优先于顶层 details.message" + ); + + // 单个超长字段不能把"有界正文"的初衷抵消掉:provider / assetKind / mediaType / code + // 都按 80 字符截断,最终文案长度因此是常数级而不是随外部正文增长。 + let long = "超".repeat(500); + let reason = editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { + "code": long.clone(), + "details": { + "message": "具体原因", + "provider": long.clone(), + "assetKind": long.clone(), + "mediaType": long.clone(), + }, + } + }) + .to_string() + .as_str(), + ) + .expect("超长字段不应让原因缺失"); + let truncated = "超".repeat(80); + assert_eq!( + reason, + format!( + "具体原因|provider {truncated}|素材类型 {truncated}|媒体类型 {truncated}|错误码 {truncated}" + ), + "每个透出字段都必须按 80 字符截断" + ); + } + async fn with_test_external_editor_credentials( api_base_url: &str, api_key: &str,