append 锁:预算与项目写锁对齐、超时报可诊断的持锁方、争用做有界退避重试
Project CI / Repository checks (pull_request) Failing after 3m22s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Native shell tests (pull_request) Successful in 17m14s

- project/agent_db.rs:等待预算从 10ms×100≈1s 改为 5ms×2000≈10s,与项目写锁完整窗口同口径;新增 lock_short(5ms×200≈1s)供只读路径使用
- project/agent_db.rs:超时文案在锁路径后追加持锁方线索(读锁文件里的诊断元数据,读不到就明说不可读),现场不再只拿到一句"检查运行时配置"
- project/agent_db.rs:取锁成功后把 label/pid/processStartedAt/acquiredAt 写进锁文件;仅作诊断,不参与判活、回收或抢占,同一进程重复取同一把锁不重写
- project/agent_db.rs:抽出 PROJECT_APPEND_LOCK_TIMEOUT_MARKER 常量,控制流不再各自复制中文
- project/conversation.rs:整份读对话记录的两处只读入口改用短窗口,避免把面板读路径一起拖住
- agent/direct_project_history.rs:争用类失败做一次有界退避重试(250ms);格式类失败不重试
- agent/direct_runtime.rs:锁争用给可操作提示(另一个客户端进程正在读写该项目历史或项目锁,请稍后重试,确认没有其它客户端再重启),retryable 保持 true,但现在确实会自动重试
- 用例:预算口径、锁文件诊断元数据、持锁方线索、被独占持有时超时且不留残留、争用重试成功、超出预算失败关闭、格式类不重试
- 变异验证:预算常量改回 1s → 预算用例红(left 10ms / right 5ms);去掉重试 → 重试用例红(append after retry 直接报跨进程锁超时);验证后已还原
This commit is contained in:
2026-09-12 01:09:37 +08:00
parent cafd989bfb
commit 9fea60b850
5 changed files with 440 additions and 12 deletions
@@ -349,6 +349,39 @@ pub(crate) fn append_direct_project_user_message_at(
append_direct_project_history_item_at_with_user_policy(root, item, true)
}
/// 争用重试的退避表:锁本身已经等满一个完整窗口(≈10s),这里只补一次短退避。
/// 再等多一轮能成功的概率已经很低,而每多一轮都会让"收尾失败"晚 10s 才告诉用户。
const DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS: &[u64] = &[250];
/// 追加写失败里"再等一会就可能成功"的那一类:跨进程锁 / 项目锁争用。
///
/// 判据只认两个常量,不各自复制中文:格式类失败(行形状、解析、注入超限)不重试——同一份
/// 历史每次读都是同一个结论,重试只是白等。
pub(crate) fn is_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)
}
/// 测试注入:让接下来 N 次单次尝试都返回"争用类失败",用来确定性地验证"重试"与"不重试"。
#[cfg(test)]
fn take_direct_project_history_contention_injection_at(root: &Path) -> Option<String> {
let path = root.join(".agent/runtime/test-fail-next-direct-project-history-append");
let remaining = fs::read_to_string(&path).ok()?.trim().parse::<u32>().ok()?;
if remaining == 0 {
let _ = fs::remove_file(&path);
return None;
}
if remaining == 1 {
let _ = fs::remove_file(&path);
} else {
let _ = fs::write(&path, (remaining - 1).to_string());
}
Some(format!(
"获取DirectProject 历史追加写{}:注入的争用失败(测试)",
crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER
))
}
fn append_direct_project_history_item_at_with_user_policy(
root: &Path,
item: &Value,
@@ -361,6 +394,30 @@ fn append_direct_project_history_item_at_with_user_policy(
if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(());
}
// `retryable = true` 只有配上"真的自动重试"才算数:争用这一类在这里做有界退避重试,
// 而不是把同一轮原样丢回给用户再点一次;格式类失败不重试(见判据函数)。
let mut last_contention: Option<String> = None;
for backoff_ms in std::iter::once(0u64).chain(
DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS
.iter()
.copied(),
) {
if backoff_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
match append_direct_project_history_item_once(root, item) {
Ok(()) => return Ok(()),
Err(error) if is_direct_project_history_contention_failure(&error) => {
last_contention = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_contention.unwrap_or_else(|| "DirectProject 历史追加写未产生结果".to_string()))
}
/// 追加写的单次尝试:锁外回扫 → 取项目锁与 append 锁 → 状态过期才在锁内重扫 → 追加。
fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result<(), String> {
let path = history_path(root);
let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
@@ -385,6 +442,10 @@ fn append_direct_project_history_item_at_with_user_policy(
None
};
fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED);
#[cfg(test)]
if let Some(error) = take_direct_project_history_contention_injection_at(root) {
return Err(error);
}
let line = record(item)?;
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
let lock = project_append_lock_for(&path)?;
@@ -569,6 +630,117 @@ mod tests {
const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#;
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
/// 之后第二次必须成功,且标记被删(证明重试真的跑过,而不是根本没走进重试分支)。
#[test]
fn contention_failure_is_retried_with_bounded_backoff() {
let root = init_history_project("contention-retry");
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
let marker = root
.path()
.join(".agent/runtime/test-fail-next-direct-project-history-append");
std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir");
std::fs::write(&marker, "1").expect("write injection marker");
let item = json!({
"type": "message",
"role": "assistant",
"id": "retry-item",
"content": [{"type": "output_text", "text": "争用后重试成功"}]
});
append_direct_project_history_item_at(root.path(), &item).expect("append after retry");
assert!(
!marker.exists(),
"注入的争用失败必须被有界重试消耗掉(证明重试真的跑过)"
);
let items = read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(
items.iter().filter(|existing| *existing == &item).count(),
1,
"重试结束后条目恰好落一行"
);
}
/// 判据:重试是**有界**的。注入超过退避预算的争用失败时必须失败关闭,
/// 剩下的注入次数原样留着(不是无限重试)。
#[test]
fn contention_failure_beyond_the_backoff_budget_fails_closed() {
let root = init_history_project("contention-bounded");
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
let marker = root
.path()
.join(".agent/runtime/test-fail-next-direct-project-history-append");
std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir");
std::fs::write(&marker, "5").expect("write injection marker");
let error = append_direct_project_history_item_at(
root.path(),
&json!({
"type": "message",
"role": "assistant",
"id": "bounded-item",
"content": [{"type": "output_text", "text": "超出重试预算"}]
}),
)
.expect_err("contention beyond the budget must fail closed");
assert!(
error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
"{error}"
);
assert_eq!(
std::fs::read_to_string(&marker)
.expect("read injection marker")
.trim()
.parse::<u32>()
.expect("marker count"),
3,
"退避表只有一次重试:注入 5 次必须还剩 3 次"
);
}
/// 判据:格式类失败**不**触发重试。同一份历史每次读都是同一个结论,重试只是白等
/// (而且会把"收尾失败"再拖一轮)。注入次数因此必须原样留着。
///
/// fixture 形状说明:回扫只对"后面还有换行的完整行"做严格解析,文件里第一段(以及末尾
/// 没有换行的那一段)按可修复尾行放宽。所以损坏行必须夹在两条完整行中间才会失败关闭。
#[test]
fn shape_failure_is_not_retried() {
let root = init_history_project("shape-no-retry");
write_history_lines(
root.path(),
&[
LEGACY_USER_ROW,
r#"{"schemaVersion":"game-creator"#,
RESPONSE_ITEM_ROW,
],
);
let marker = root
.path()
.join(".agent/runtime/test-fail-next-direct-project-history-append");
std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir");
std::fs::write(&marker, "5").expect("write injection marker");
let error = append_direct_project_history_item_at(
root.path(),
&json!({
"type": "message",
"role": "assistant",
"id": "shape-item",
"content": [{"type": "output_text", "text": "格式类失败"}]
}),
)
.expect_err("shape failure must fail closed");
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
assert_eq!(
std::fs::read_to_string(&marker).expect("read injection marker"),
"5",
"格式类失败不重试:注入次数不能被消耗"
);
}
/// 判据:幂等回扫发生时,append 锁**不能**在本进程手里。
///
/// 变异验证(把回扫挪回锁内)会把这条用例打成红的:探针在回扫时点用与生产同一套
@@ -1792,6 +1792,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
if direct_project_history_shape_failure(error) {
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
}
if direct_project_history_contention_failure(error) {
return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
}
match stage {
DirectCodexFailureStage::ArtPreparation => {
"平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断"
@@ -1877,6 +1880,17 @@ fn direct_project_history_shape_failure(error: &str) -> bool {
.any(|marker| error.contains(marker))
}
/// 跨进程锁 / 项目锁争用。
///
/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身
/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此
/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。
/// 判据只认两个常量,不各自复制中文。
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 {
let normalized = error.to_ascii_lowercase();
error.contains("泥点余额不足")
@@ -3537,6 +3537,16 @@ pub(crate) struct ProjectAppendGuard<'a> {
_os_lock: File,
}
/// append 锁的等待口径与项目写锁对齐:`agent/runtime_actions/project_gates.rs:1821-1823`
/// 是 5ms × 2000(≈10s 完整窗口)/ 200(≈1s 短窗口),这里曾经是 10ms × 100 ≈ 1s。
const PROJECT_APPEND_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5);
const PROJECT_APPEND_LOCK_WAIT_ATTEMPTS: usize = 2_000;
const PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
/// 锁超时文案里的稳定片段:控制流(DirectProject 失败提示、收尾重试判据)只认这个常量,
/// 不各自复制一份中文(改一次文案等于改一次重试语义)。
pub(crate) const PROJECT_APPEND_LOCK_TIMEOUT_MARKER: &str = "跨进程锁超时";
impl ProjectAppendLock {
fn lock_process(&self, error_label: &str) -> Result<std::sync::MutexGuard<'_, ()>, String> {
self.process_lock
@@ -3544,12 +3554,30 @@ impl ProjectAppendLock {
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
}
/// 追加写入口,走完整等待窗口(≈10s)。这条等待的失败会直接变成用户可见的
/// "写历史失败",而持锁方可能正在做一件 O(文件大小) 的事(整份读历史)甚至等 UAC,
/// 1s 的窗口在这种对手面前必然打满。
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_WAIT_ATTEMPTS)
}
/// 只读入口,沿用短窗口(≈1s):调用方本来就会重跑(面板刷新、下一轮 prompt 组装),
/// 长时间阻塞只会把它一起拖住。
pub(crate) fn lock_short(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS)
}
fn lock_with_attempts(
&self,
error_label: &str,
max_attempts: usize,
) -> Result<ProjectAppendGuard<'_>, String> {
let process_guard = self
.process_lock
.lock()
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?;
let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label)?;
let os_lock =
acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?;
Ok(ProjectAppendGuard {
_process_guard: process_guard,
_os_lock: os_lock,
@@ -3606,21 +3634,91 @@ fn project_append_os_lock_path(path: &Path) -> Result<PathBuf, String> {
.join(format!("{}.lock", &fingerprint[..32])))
}
fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result<File, String> {
fn acquire_project_append_os_lock(
path: &Path,
error_label: &str,
max_attempts: usize,
) -> Result<File, String> {
if let Some(parent) = path.parent() {
ensure_game_creator_private_directory_tree(parent, error_label)?;
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
}
prepare_game_creator_private_path_for_read(path, false, error_label)?;
for attempt in 0..100 {
if let Some(file) = try_open_project_append_os_lock(path, error_label)? {
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);
return Ok(file);
}
if attempt < 99 {
thread::sleep(Duration::from_millis(10));
if attempt + 1 < max_attempts {
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
}
}
Err(format!("获取{error_label}跨进程锁超时:{}", path.display()))
Err(format!(
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}{}{}",
path.display(),
project_append_lock_holder_diagnostic(path)
))
}
/// 超时时报出"谁在持锁",只读锁文件里的诊断元数据。
///
/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是
/// 一句"检查运行时配置",那既不是现象也不是动作。
fn project_append_lock_holder_diagnostic(path: &Path) -> String {
let Ok(content) = fs::read_to_string(path) else {
return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string();
};
let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) else {
return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string();
};
let pid = record.get("pid").and_then(serde_json::Value::as_u64);
let label = record.get("label").and_then(serde_json::Value::as_str);
let started_at = record
.get("processStartedAt")
.and_then(serde_json::Value::as_u64);
match (pid, label, started_at) {
(Some(pid), Some(label), Some(started_at)) => {
format!("持锁方 pid={pid}(进程启动于 {started_at},取锁用途 {label}")
}
(Some(pid), Some(label), None) => format!("持锁方 pid={pid}(取锁用途 {label}"),
(Some(pid), None, _) => format!("持锁方 pid={pid}"),
(None, ..) => "持锁方身份不可读:锁文件里没有 pid".to_string(),
}
}
/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写。
///
/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与
/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md
/// 2026-09-09「项目写锁残留回收与启动诊断」)。
fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
let pid = std::process::id();
let mut current = String::new();
if file.seek(SeekFrom::Start(0)).is_ok()
&& std::io::Read::read_to_string(file, &mut current).is_ok()
&& serde_json::from_str::<serde_json::Value>(&current)
.ok()
.and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64))
== Some(u64::from(pid))
{
return;
}
let Ok(serialized) = serde_json::to_string(&serde_json::json!({
"acquiredAt": unix_timestamp(),
"label": error_label,
"pid": pid,
"processStartedAt": crate::project::project_write_lock_process_start_time_seconds(
u64::from(pid)
),
})) else {
return;
};
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();
}
/// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。
@@ -3758,9 +3856,8 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
}
}
// 严格校验留在持锁期间(对象就是刚打开的这个句柄),**修复移到锁外**:提权修复会
// `powershell.exe -Command "Start-Process -Verb RunAs -Wait …"` 同步等用户点 UAC
// config.rs 的 attempt_elevated_windows_acl_repair),在这里等它等于把"用户犹豫
// 的时间"记进别人的持锁窗口——1s 预算必然打满,而且只有 Windows 有这条路径。
// `Start-Process -Verb RunAs -Wait` 同步等用户点 UAC,在这里等它等于把"用户犹豫的
// 时间"记进别人的持锁窗口(1s 预算必然打满,而且只有 Windows 有这条路径)。
if let Err(error) =
crate::secure_windows_game_creator_path_for_current_user(path, false, true)
{
@@ -3088,3 +3088,146 @@ fn agent_db_tail_repair_handles_an_exact_limit_record() {
fs::remove_dir_all(valid_root).ok();
fs::remove_dir_all(invalid_root).ok();
}
/// append 锁的等待预算必须与项目写锁的完整窗口同口径。
///
/// 它曾经是 10ms × 100 ≈ 1s,而持锁方做的可能是"整份读一份 7MB 历史"甚至等 UAC
/// 那个窗口必然打满,打满的后果是用户直接看到"写历史失败"。这条用例把口径钉在常量上,
/// 谁把它改回 1s 都会红。
#[test]
fn append_lock_wait_budget_matches_the_project_write_lock_window() {
assert_eq!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL,
std::time::Duration::from_millis(5)
);
assert_eq!(PROJECT_APPEND_LOCK_WAIT_ATTEMPTS, 2_000);
assert_eq!(PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS, 200);
assert!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 2_000 >= std::time::Duration::from_secs(10),
"append 锁完整窗口必须 >= 项目写锁的 10s"
);
assert!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 200 <= std::time::Duration::from_secs(1),
"只读短窗口必须留在 1s 级,避免把面板读路径一起拖住"
);
}
fn append_lock_test_target(test_name: &str) -> (PathBuf, PathBuf) {
let root = unique_agent_db_test_root(test_name);
let target = root.join(".agent/conversations/project.jsonl");
fs::create_dir_all(target.parent().expect("history parent")).expect("history dir");
(root, target)
}
#[cfg(windows)]
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(0)
.open(path)
.expect("hold lock file exclusively")
}
#[cfg(unix)]
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.expect("open lock file");
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(result, 0, "flock lock file");
file
}
/// 锁文件必须留下"谁在持锁"的诊断元数据:零字节锁文件让现场无法回答是谁在持锁,
/// 而"可操作提示"正是靠它才能成立。这里也钉住"只诊断、不抢占"——元数据只被读来报错。
#[test]
fn append_lock_file_records_the_holder_for_postmortem() {
let (root, target) = append_lock_test_target("append-lock-diagnostic");
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 raw = fs::read_to_string(&lock_path).expect("read lock file");
let record: serde_json::Value =
serde_json::from_str(&raw).expect("lock file carries diagnostic json");
assert_eq!(
record.get("pid").and_then(serde_json::Value::as_u64),
Some(u64::from(std::process::id())),
"{raw}"
);
assert_eq!(
record.get("label").and_then(serde_json::Value::as_str),
Some("DirectProject 历史追加写"),
"{raw}"
);
assert!(record.get("processStartedAt").is_some(), "{raw}");
assert!(record.get("acquiredAt").is_some(), "{raw}");
fs::remove_dir_all(&root).ok();
}
#[test]
fn append_lock_holder_diagnostic_reports_the_recorded_pid() {
let (root, target) = append_lock_test_target("append-lock-holder-diagnostic");
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(
&lock_path,
"{\"acquiredAt\":1,\"label\":\"测试用途\",\"pid\":4242,\"processStartedAt\":7}",
)
.expect("seed lock file");
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
assert!(diagnostic.contains('7'), "{diagnostic}");
assert!(diagnostic.contains("测试用途"), "{diagnostic}");
fs::remove_file(&lock_path).ok();
let missing = project_append_lock_holder_diagnostic(&lock_path);
assert!(missing.contains("身份不可读"), "{missing}");
fs::remove_dir_all(&root).ok();
}
/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**——
/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。
#[test]
fn append_lock_short_wait_times_out_with_the_holder_and_leaks_nothing() {
let (root, target) = append_lock_test_target("append-lock-timeout");
let lock = project_append_lock_for(&target).expect("resolve lock");
// 先让生产代码创建并收紧锁文件(本地 harden 只在首次创建时发生)。
drop(
lock.lock_short("DirectProject 历史追加写")
.expect("first acquire"),
);
let lock_path = project_append_os_lock_path(&target).expect("lock path");
let held = hold_lock_file_exclusively_for_test(&lock_path);
let error = match lock.lock_short("DirectProject 历史追加写") {
Ok(_guard) => panic!("exclusively held lock must not be acquired"),
Err(error) => error,
};
assert!(
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
"{error}"
);
assert!(
error.contains("持锁方"),
"超时文案必须回答是谁在持锁:{error}"
);
drop(held);
drop(
lock.lock_short("DirectProject 历史追加写")
.expect("reacquire after release"),
);
fs::remove_dir_all(&root).ok();
}
@@ -1063,7 +1063,9 @@ pub(crate) fn read_local_conversation_for_session_at(
}
};
let append_lock = project_append_lock_for(&path)?;
let _append_guard = append_lock.lock("对话记录追加写")?;
// 只读路径用短窗口:整份读只要一份当前快照,调用方(面板刷新、下一轮 prompt 组装)
// 自己会重跑,长时间阻塞只会把它一起拖住;写路径继续用完整窗口。
let _append_guard = append_lock.lock_short("对话记录追加写")?;
let records = read_persisted_local_conversation_records_unlocked(&path)?;
Ok(local_conversation_result_from_persisted_records(
&path,
@@ -1113,7 +1115,7 @@ fn read_local_conversation_message_by_id_for_session_internal_at(
let (path, normalized_agent_id, normalized_session_id) =
conversation_file_path_for_session(root, agent_id, session_id)?;
let append_lock = project_append_lock_for(&path)?;
let _append_guard = append_lock.lock("对话记录追加写")?;
let _append_guard = append_lock.lock_short("对话记录追加写")?;
let records = read_persisted_local_conversation_records_unlocked(&path)?;
let matched_index = persisted_local_conversation_message_index_by_id(
&records,