append 锁:预算与项目写锁对齐、超时报可诊断的持锁方、争用做有界退避重试
- 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:
@@ -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>(¤t)
|
||||
.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,
|
||||
|
||||
Reference in New Issue
Block a user