修复 AGC 项目写锁回收的并发删除竞争与启动诊断死路径
- 锁文件回收改为单次快照解析:payload 只解析一次,删除前重新核对字节,内容已被并发方替换或文件已消失时返回 false 并重试 create_new,不再无条件 unlink,也不再把 remove_file 的 NotFound 当成硬失败 - mtime 读取失败改用 Option 区分“未知”与“纪元 0”:未知年龄不回收,未知锁创建时间不做 PID 复用推断,避免把保守判定反转成抢走活持有者 - 新增并发替换/已消失、旧格式活持有者、存活未知、mtime 未知四条回归用例 - 启动日志槽优先用已经生效的配置目录(含 --config-dir),否则退到平台配置根(APPDATA / Application Support / XDG_CONFIG_HOME) - 日志路径未知时 StartupLogSlot::fail 仍然给出用户可见提示,四处 inspect_err 不再被路径判空挡掉 - 同步 check-config 守卫、decision-log、pitfalls 与技术方案文档的用例数与新判据
This commit is contained in:
@@ -1711,7 +1711,10 @@ if (
|
||||
) ||
|
||||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
|
||||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
|
||||
!tauriHandlerSource.includes('show_startup_error_dialog(&path)')
|
||||
!tauriHandlerSource.includes(
|
||||
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
|
||||
) ||
|
||||
!tauriHandlerSource.includes('early_startup_log_path(')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
|
||||
|
||||
@@ -1999,9 +1999,10 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat
|
||||
}
|
||||
|
||||
/// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志
|
||||
/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。
|
||||
/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。日志路径尚未
|
||||
/// 确定时仍然要提示,只是不给路径。
|
||||
#[cfg(windows)]
|
||||
fn show_startup_error_dialog(log_path: &Path) {
|
||||
fn show_startup_error_dialog(log_path: Option<&Path>) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
|
||||
@@ -2014,10 +2015,15 @@ fn show_startup_error_dialog(log_path: &Path) {
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let message_text = format!(
|
||||
let message_text = match log_path {
|
||||
Some(log_path) => format!(
|
||||
"应用启动失败,请把下面的诊断日志发给开发人员:\n{}",
|
||||
log_path.display()
|
||||
);
|
||||
),
|
||||
None => {
|
||||
"应用启动失败,诊断日志路径尚未确定;请把这条提示和复现步骤发给开发人员。".to_string()
|
||||
}
|
||||
};
|
||||
let message = std::ffi::OsStr::new(&message_text)
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
@@ -2034,14 +2040,56 @@ fn show_startup_error_dialog(log_path: &Path) {
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_startup_error_dialog(log_path: &Path) {
|
||||
fn show_startup_error_dialog(log_path: Option<&Path>) {
|
||||
if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
match log_path {
|
||||
Some(log_path) => eprintln!(
|
||||
"Genarrative AI Game Creator startup failed; see {}",
|
||||
log_path.display()
|
||||
);
|
||||
),
|
||||
None => eprintln!(
|
||||
"Genarrative AI Game Creator startup failed before the diagnostics log path was known"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir`
|
||||
/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时
|
||||
/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。
|
||||
fn early_startup_log_path(identifier: &str) -> Option<PathBuf> {
|
||||
let configured_dir = game_creator_runtime_config_dir();
|
||||
resolve_early_startup_log_path(configured_dir.as_deref(), identifier)
|
||||
}
|
||||
|
||||
fn resolve_early_startup_log_path(
|
||||
configured_dir: Option<&Path>,
|
||||
identifier: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match configured_dir {
|
||||
Some(directory) => Some(directory.join("diagnostics/startup.log")),
|
||||
None => {
|
||||
platform_config_root().map(|root| root.join(identifier).join("diagnostics/startup.log"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_config_root() -> Option<PathBuf> {
|
||||
std::env::var_os("APPDATA").map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_config_root() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
fn platform_config_root() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||
}
|
||||
|
||||
/// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按
|
||||
@@ -2074,12 +2122,11 @@ impl StartupLogSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动阶段的致命失败:先落盘,再给出用户可见提示。
|
||||
/// 启动阶段的致命失败:先落盘,再给出用户可见提示。日志路径未知时仍然要
|
||||
/// 提示,否则早期失败依旧表现为“双击没反应”。
|
||||
fn fail(&self, line: &str) {
|
||||
self.append(line);
|
||||
if let Some(path) = self.path() {
|
||||
show_startup_error_dialog(&path);
|
||||
}
|
||||
show_startup_error_dialog(self.path().as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2407,17 +2454,12 @@ fn main() {
|
||||
}
|
||||
|
||||
let mut tauri_context = tauri::generate_context!();
|
||||
// 配置目录确定之前先按标识符推导 APPDATA 下的日志路径,确定后再切到真实配置
|
||||
// 目录,保证 `configure_game_creator_runtime_config_dir` 自身失败也有落点。
|
||||
let startup_log = Arc::new(StartupLogSlot::new(
|
||||
std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.map(|appdata| {
|
||||
appdata
|
||||
.join(tauri_context.config().identifier.as_str())
|
||||
.join("diagnostics/startup.log")
|
||||
}),
|
||||
));
|
||||
// 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如
|
||||
// `--config-dir`),否则退到平台配置根,保证
|
||||
// `configure_game_creator_runtime_config_dir` 自身失败也有落点。
|
||||
let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path(
|
||||
tauri_context.config().identifier.as_str(),
|
||||
)));
|
||||
let setup_log = Arc::clone(&startup_log);
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -2432,12 +2474,13 @@ fn main() {
|
||||
setup_log.append("startup.setup.begin");
|
||||
setup_log.append("startup.appdata.configure.begin");
|
||||
configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
|
||||
// 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。
|
||||
let config_dir = game_creator_runtime_config_dir();
|
||||
let details =
|
||||
sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref());
|
||||
setup_log.fail(&format!(
|
||||
"startup.appdata.configure.failed details={details}"
|
||||
));
|
||||
}
|
||||
})?;
|
||||
if let Some(directory) = game_creator_runtime_config_dir() {
|
||||
setup_log.set(directory.join("diagnostics/startup.log"));
|
||||
@@ -2460,13 +2503,10 @@ fn main() {
|
||||
setup_log.append("startup.runner.configure.begin");
|
||||
configure_external_agent_runner(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.configure.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -2477,13 +2517,10 @@ fn main() {
|
||||
setup_log.append("startup.runner.configure.complete");
|
||||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -2499,13 +2536,10 @@ fn main() {
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.attach-owner.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -2734,6 +2768,36 @@ mod diagnostic_log_tests {
|
||||
assert!(content.contains("startup.appdata.configure.complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_startup_log_path_prefers_the_already_applied_config_dir() {
|
||||
let directory = tempfile::tempdir().expect("create config directory");
|
||||
assert_eq!(
|
||||
resolve_early_startup_log_path(Some(directory.path()), "world.genarrative.test"),
|
||||
Some(directory.path().join("diagnostics/startup.log"))
|
||||
);
|
||||
|
||||
// 配置目录尚未生效时才退到平台配置根,且仍要按标识符分层。
|
||||
if let Some(fallback) = resolve_early_startup_log_path(None, "world.genarrative.test") {
|
||||
assert!(fallback.ends_with("world.genarrative.test/diagnostics/startup.log"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_log_slot_fail_without_path_still_reports_instead_of_going_silent() {
|
||||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||||
let slot = StartupLogSlot::new(None);
|
||||
|
||||
// 路径未知时 fail 不能静默:它必须仍然走到用户可见提示,同时不造日志文件。
|
||||
slot.fail("startup.runner.owner-lock.failed details=test");
|
||||
assert_eq!(fs::read_dir(directory.path()).expect("read dir").count(), 0);
|
||||
|
||||
let path = directory.path().join("startup.log");
|
||||
let slot = StartupLogSlot::new(Some(path.clone()));
|
||||
slot.fail("startup.runner.owner-lock.failed details=test");
|
||||
let content = fs::read_to_string(&path).expect("read startup log");
|
||||
assert!(content.contains("startup.runner.owner-lock.failed details=test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -202,81 +202,92 @@ pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) ->
|
||||
None
|
||||
}
|
||||
|
||||
fn project_write_lock_owner_pid(path: &Path) -> Option<u64> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
|
||||
.and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64))
|
||||
/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照:
|
||||
/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息
|
||||
/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ProjectWriteLockSnapshot {
|
||||
content: Vec<u8>,
|
||||
pid: Option<u64>,
|
||||
created_at: Option<u64>,
|
||||
process_started_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn project_write_lock_owner_created_at(path: &Path) -> Option<u64> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
|
||||
.and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64))
|
||||
}
|
||||
|
||||
fn project_write_lock_owner_started_at(path: &Path) -> Option<u64> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
|
||||
.and_then(|payload| {
|
||||
impl ProjectWriteLockSnapshot {
|
||||
pub(crate) fn read(path: &Path) -> Option<Self> {
|
||||
let content = fs::read(path).ok()?;
|
||||
let payload = serde_json::from_slice::<serde_json::Value>(&content).ok();
|
||||
let number = |key: &str| {
|
||||
payload
|
||||
.get("processStartedAt")
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.get(key))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
};
|
||||
Some(Self {
|
||||
pid: number("pid"),
|
||||
created_at: number("createdAt"),
|
||||
process_started_at: number("processStartedAt"),
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool {
|
||||
project_write_lock_owner_pid(path) == Some(u64::from(std::process::id()))
|
||||
ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid)
|
||||
== Some(u64::from(std::process::id()))
|
||||
}
|
||||
|
||||
fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> u64 {
|
||||
/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知”
|
||||
/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成
|
||||
/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。
|
||||
fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option<u64> {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 {
|
||||
if let Some(created_at) = project_write_lock_owner_created_at(path) {
|
||||
return unix_timestamp().saturating_sub(created_at);
|
||||
/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能
|
||||
/// 按“不回收”处理,不能退化成 0 或极大值。
|
||||
fn project_write_lock_age_seconds(
|
||||
snapshot: &ProjectWriteLockSnapshot,
|
||||
modified_at: Option<u64>,
|
||||
now: u64,
|
||||
) -> Option<u64> {
|
||||
if let Some(created_at) = snapshot.created_at {
|
||||
return Some(now.saturating_sub(created_at));
|
||||
}
|
||||
let modified_at = project_write_lock_file_modified_seconds(metadata);
|
||||
unix_timestamp().saturating_sub(modified_at)
|
||||
modified_at.map(|modified_at| now.saturating_sub(modified_at))
|
||||
}
|
||||
|
||||
fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程
|
||||
/// 难以构造的分支(存活状态无法判定、mtime 不可读)。
|
||||
pub(crate) fn project_write_lock_reclaim_decision(
|
||||
snapshot: &ProjectWriteLockSnapshot,
|
||||
modified_at: Option<u64>,
|
||||
now: u64,
|
||||
process_is_alive: impl Fn(u64) -> Option<bool>,
|
||||
process_started_at: impl Fn(u64) -> Option<u64>,
|
||||
) -> bool {
|
||||
let Some(owner_pid) = snapshot.pid else {
|
||||
// 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。
|
||||
return project_write_lock_age_seconds(snapshot, modified_at, now)
|
||||
.is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS);
|
||||
};
|
||||
if metadata.file_type().is_symlink()
|
||||
|| windows_metadata_is_reparse_point(&metadata)
|
||||
|| !metadata.is_file()
|
||||
|| metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let created_at = project_write_lock_owner_created_at(path);
|
||||
let Some(owner_pid) = project_write_lock_owner_pid(path) else {
|
||||
// 没有任何持有者信息:只可能是崩溃在落盘 payload 之前留下的空锁或坏锁。
|
||||
return project_write_lock_age_seconds(path, &metadata)
|
||||
> PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS;
|
||||
};
|
||||
match project_write_lock_process_is_alive(owner_pid) {
|
||||
match process_is_alive(owner_pid) {
|
||||
Some(false) => true,
|
||||
Some(true) => {
|
||||
// PID 会被系统复用,必须确认当前同名进程就是当时的持有者。
|
||||
let actual_started_at = project_write_lock_process_start_time_seconds(owner_pid);
|
||||
match (project_write_lock_owner_started_at(path), actual_started_at) {
|
||||
match (snapshot.process_started_at, process_started_at(owner_pid)) {
|
||||
// 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。
|
||||
(Some(stored), Some(actual)) => stored != actual,
|
||||
// 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用。
|
||||
// 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用;
|
||||
// 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。
|
||||
(None, Some(actual)) => {
|
||||
let lock_created_at = created_at
|
||||
.unwrap_or_else(|| project_write_lock_file_modified_seconds(&metadata));
|
||||
let Some(lock_created_at) = snapshot.created_at.or(modified_at) else {
|
||||
return false;
|
||||
};
|
||||
actual
|
||||
> lock_created_at
|
||||
.saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS)
|
||||
@@ -284,11 +295,53 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// 无法判定持有者是否存活时保持原有保守策略:只有明显过期才回收。
|
||||
None => {
|
||||
project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS
|
||||
// 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。
|
||||
None => project_write_lock_age_seconds(snapshot, modified_at, now)
|
||||
.is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。
|
||||
fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option<ProjectWriteLockSnapshot> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| windows_metadata_is_reparse_point(&metadata)
|
||||
|| !metadata.is_file()
|
||||
|| metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let snapshot = ProjectWriteLockSnapshot::read(path)?;
|
||||
project_write_lock_reclaim_decision(
|
||||
&snapshot,
|
||||
project_write_lock_file_modified_seconds(&metadata),
|
||||
unix_timestamp(),
|
||||
project_write_lock_process_is_alive,
|
||||
project_write_lock_process_start_time_seconds,
|
||||
)
|
||||
.then_some(snapshot)
|
||||
}
|
||||
|
||||
/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的
|
||||
/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或
|
||||
/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。
|
||||
pub(crate) fn project_write_lock_reclaim(
|
||||
path: &Path,
|
||||
snapshot: &ProjectWriteLockSnapshot,
|
||||
) -> Result<bool, String> {
|
||||
match fs::read(path) {
|
||||
Ok(content) if content == snapshot.content => {}
|
||||
Ok(_) => return Ok(false),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(format!("读取失效项目写锁失败:{}: {error}", path.display()));
|
||||
}
|
||||
}
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool {
|
||||
@@ -420,20 +473,17 @@ pub(crate) fn acquire_project_write_lock(
|
||||
bypassed_same_process: false,
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if project_write_lock_open_error_is_contention(&error)
|
||||
&& !retried_after_reclaim
|
||||
&& project_write_lock_can_be_reclaimed(&path) =>
|
||||
{
|
||||
fs::remove_file(&path).map_err(|error| {
|
||||
format!("清理失效项目写锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
Err(error) if project_write_lock_open_error_is_contention(&error) => {
|
||||
if !retried_after_reclaim {
|
||||
if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) {
|
||||
if project_write_lock_reclaim(&path, &snapshot)? {
|
||||
retried_after_reclaim = true;
|
||||
continue;
|
||||
}
|
||||
Err(error)
|
||||
if project_write_lock_open_error_is_contention(&error)
|
||||
&& crate::agent::autonomous_game_build_root_run_active_at(root)
|
||||
&& project_write_lock_is_owned_by_current_process(&path) =>
|
||||
}
|
||||
}
|
||||
if crate::agent::autonomous_game_build_root_run_active_at(root)
|
||||
&& project_write_lock_is_owned_by_current_process(&path)
|
||||
{
|
||||
// The autonomous game-build lane intentionally permits
|
||||
// parallel specialist actions. If the durable lock belongs
|
||||
@@ -446,7 +496,6 @@ pub(crate) fn acquire_project_write_lock(
|
||||
bypassed_same_process: true,
|
||||
});
|
||||
}
|
||||
Err(error) if project_write_lock_open_error_is_contention(&error) => {
|
||||
return Err(format!("项目正在被其他写操作占用:{}", path.display()));
|
||||
}
|
||||
Err(error) => {
|
||||
|
||||
@@ -37,6 +37,13 @@ fn backdate_project_lock_fixture(root: &Path, seconds: u64) {
|
||||
.expect("回拨项目写锁 fixture mtime");
|
||||
}
|
||||
|
||||
/// 写入锁 fixture 并读回同一份快照,供纯判据用例使用。
|
||||
fn project_lock_snapshot(root: &Path, content: &[u8]) -> ProjectWriteLockSnapshot {
|
||||
write_project_lock_fixture(root, content);
|
||||
ProjectWriteLockSnapshot::read(&root.join(PROJECT_LOCK_RELATIVE_PATH))
|
||||
.expect("读取项目写锁快照")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_unrelated_live_process() -> std::process::Child {
|
||||
std::process::Command::new("ping")
|
||||
@@ -82,9 +89,9 @@ fn project_write_lock_reclaims_dead_owner_pid() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 A0:锁文件被外部改写或截断损坏,PID 超出平台进程号空间(例如
|
||||
/// u64::MAX)。它不可能属于任何活进程,必须直接回收,而不是让项目再等满
|
||||
/// 600 秒;Unix 的 pid_t 只有有符号 32 位,越界取值尤其容易在这里被漏掉。
|
||||
/// 锁文件被外部改写或截断损坏、PID 超出平台进程号空间(例如 u64::MAX)时,它不
|
||||
/// 可能属于任何活进程,必须直接回收而不是再等满 600 秒;Unix 的 pid_t 只有有符号
|
||||
/// 32 位,越界取值尤其容易在这里被漏掉。
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_unrepresentable_owner_pid() {
|
||||
let root = unique_project_path();
|
||||
@@ -104,8 +111,8 @@ fn project_write_lock_reclaims_unrepresentable_owner_pid() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 A:崩溃发生在 create_new 成功、payload 写盘之前,留下 0 字节锁。
|
||||
/// 现在要等满 600 秒才会回收,重启后 10 分钟内所有写操作都失败。
|
||||
/// 空锁超过 30 秒宽限期即回收:崩溃停在 `create_new` 与落盘 payload 之间时写入方
|
||||
/// 已经放弃,不能继续阻塞项目。
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_empty_body_after_short_grace() {
|
||||
let root = unique_project_path();
|
||||
@@ -123,8 +130,8 @@ fn project_write_lock_reclaims_empty_body_after_short_grace() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 B:崩溃后 Windows 把同一个 PID 复用给了另一个无关进程。
|
||||
/// 只要那个进程还活着,残留锁就永远不会被回收。
|
||||
/// 旧格式锁(无 `processStartedAt`)记录的 PID 已被复用给另一个活进程时,按“进程
|
||||
/// 启动时间晚于锁创建时间”推断原持有者已退出并回收。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_pid_reused_by_other_live_process() {
|
||||
@@ -150,8 +157,8 @@ fn project_write_lock_reclaims_pid_reused_by_other_live_process() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 C:新锁自带进程启动身份。即使时间戳看起来“刚刚写过”,只要身份对不上
|
||||
/// 就说明是 PID 复用,必须回收;这条路径不依赖系统时钟是否发生跳变。
|
||||
/// 新格式锁自带进程启动身份:即使时间戳看起来“刚刚写过”,只要身份对不上就判定
|
||||
/// PID 复用并回收;这条路径不依赖系统时钟是否发生跳变。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_pid_reused_identity_mismatch() {
|
||||
@@ -230,3 +237,122 @@ fn project_write_lock_keeps_fresh_empty_body() {
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:旧格式锁(有 `pid` 和 `createdAt`、没有 `processStartedAt`)的持有者确实
|
||||
/// 活着,且进程启动时间早于锁创建时间时,不能被当成 PID 复用抢走。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_keeps_live_old_format_holder_started_before_created_at() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-old-format-live", "锁回收-旧格式活持有者")
|
||||
.expect("初始化项目");
|
||||
let unrelated = spawn_unrelated_live_process();
|
||||
let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id()))
|
||||
.expect("读取活进程启动时间");
|
||||
// 锁是在持有者启动之后才写下的,所以进程启动时间自然早于 createdAt。
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&project_lock_fixture_payload(u64::from(unrelated.id()), started_at + 60),
|
||||
);
|
||||
|
||||
let error = acquire_project_write_lock(&root, "repro.acquire-concurrent");
|
||||
stop_unrelated_live_process(unrelated);
|
||||
assert!(
|
||||
matches!(&error, Err(message) if message.contains("项目正在被其他写操作占用")),
|
||||
"旧格式活持有者不能被当成 PID 复用,实际结果:{error:?}"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:回收判定只是快照观察,删除前必须重新核对内容。判定之后被并发方替换成
|
||||
/// 自己的活锁时不能删除它;文件已经消失时也不算失败。
|
||||
#[test]
|
||||
fn project_write_lock_reclaim_skips_replaced_or_removed_lock_file() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-reclaim-race", "锁回收-并发替换").expect("初始化项目");
|
||||
let lock_path = root.join(PROJECT_LOCK_RELATIVE_PATH);
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&project_lock_fixture_payload(4_242, unix_timestamp()),
|
||||
);
|
||||
let stale_snapshot = ProjectWriteLockSnapshot::read(&lock_path).expect("读取残留锁快照");
|
||||
|
||||
// 并发方回收了残留锁并装上了自己的活锁。
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&project_lock_fixture_payload(u64::from(std::process::id()), unix_timestamp()),
|
||||
);
|
||||
assert!(
|
||||
!project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对被替换的锁文件"),
|
||||
"内容已经变化时必须放弃删除"
|
||||
);
|
||||
assert!(lock_path.exists(), "并发方新装的活锁不能被删掉");
|
||||
|
||||
fs::remove_file(&lock_path).expect("删除锁文件");
|
||||
assert!(
|
||||
!project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对已消失的锁文件"),
|
||||
"锁文件已经消失时不算失败"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:存活状态无法判定(`None`)时保持保守——年轻锁必须保留,明显过期才回收。
|
||||
#[test]
|
||||
fn project_write_lock_decision_keeps_young_lock_when_liveness_is_unknown() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-unknown-liveness", "锁回收-存活未知")
|
||||
.expect("初始化项目");
|
||||
let now = unix_timestamp();
|
||||
let snapshot = project_lock_snapshot(&root, &project_lock_fixture_payload(4_242, now));
|
||||
|
||||
assert!(
|
||||
!project_write_lock_reclaim_decision(&snapshot, Some(now), now, |_| None, |_| None),
|
||||
"存活状态无法判定时,新鲜锁必须保持占用"
|
||||
);
|
||||
assert!(
|
||||
project_write_lock_reclaim_decision(&snapshot, Some(now), now + 601, |_| None, |_| None),
|
||||
"存活状态无法判定且明显过期时必须回收"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:mtime 不可读(未知)时不能退化成“年龄极大”。未知年龄按不回收处理,
|
||||
/// 未知创建时间也不能满足“启动时间晚于创建时间”的 PID 复用推断。
|
||||
#[test]
|
||||
fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-unknown-mtime", "锁回收-mtime未知")
|
||||
.expect("初始化项目");
|
||||
let now = unix_timestamp();
|
||||
|
||||
let empty = project_lock_snapshot(&root, b"");
|
||||
assert!(
|
||||
!project_write_lock_reclaim_decision(&empty, None, now + 601, |_| None, |_| None),
|
||||
"mtime 未知时空锁必须保持占用"
|
||||
);
|
||||
assert!(
|
||||
project_write_lock_reclaim_decision(&empty, Some(now - 120), now, |_| None, |_| None),
|
||||
"mtime 已知且超过宽限期的空锁必须回收"
|
||||
);
|
||||
|
||||
let live_old_format = project_lock_snapshot(
|
||||
&root,
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"commandId": "repro.live-writer",
|
||||
"pid": 4_242,
|
||||
"nonce": 1,
|
||||
}))
|
||||
.expect("序列化项目写锁 fixture"),
|
||||
);
|
||||
assert!(
|
||||
!project_write_lock_reclaim_decision(
|
||||
&live_old_format,
|
||||
None,
|
||||
now + 601,
|
||||
|_| Some(true),
|
||||
|_| Some(now + 3_600)
|
||||
),
|
||||
"创建时间未知时不能按 PID 复用抢走活持有者"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -8191,8 +8191,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
|
||||
## 2026-09-09 项目写锁残留回收与启动诊断
|
||||
|
||||
- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒。活持有者始终不回收。
|
||||
- 决策:启动诊断日志用 `StartupLogSlot` 先按标识符推导 APPDATA 路径、配置目录就绪后切换,`startup.*.failed` 与 `show_startup_error_dialog` 必须可达;Windows 启动失败弹系统消息框,其它平台写 stderr。
|
||||
- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断,锁创建时间未知时不做该推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒,mtime 不可读时按未知年龄不回收。活持有者始终不回收。
|
||||
- 决策:回收判据与删除基于同一次读到的锁文件快照——payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
||||
- 决策:启动诊断日志用 `StartupLogSlot`,优先用已生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),配置目录就绪后再切换;`startup.*.failed` 与 `show_startup_error_dialog` 必须可达,日志路径未知时也必须给出用户可见提示;Windows 启动失败弹系统消息框,其它平台写 stderr。
|
||||
- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
|
||||
- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
|
||||
- 验证:`project_lock_recovery` 7 条与 `diagnostic_log` 5 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
|
||||
- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
|
||||
|
||||
@@ -5039,3 +5039,11 @@
|
||||
- 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。
|
||||
- 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。
|
||||
|
||||
## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09)
|
||||
|
||||
- 现象:两个实例同时恢复同一个崩溃项目时,后判定的一方可能删掉另一方刚装上的活锁;`remove_file` 的 `NotFound` 还会被当成硬失败,直接报“清理失效项目写锁失败”。
|
||||
- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
|
||||
- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
|
||||
- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。
|
||||
|
||||
@@ -1333,6 +1333,7 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
|
||||
## 2026-09-09 项目写锁残留回收与启动诊断
|
||||
|
||||
- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活时继续按 600 秒保守回收,活持有者仍然不回收。
|
||||
- 启动诊断日志改为 `StartupLogSlot`:`configure_game_creator_runtime_config_dir` 之前按应用标识符推导 APPDATA 路径,成功后再切换到真实配置目录,`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
|
||||
- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 6 条(死 PID、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、新鲜空锁不抢锁)、`diagnostic_log` 5 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
|
||||
- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断,锁创建时间未知时不做该推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活、或 mtime 不可读时保持保守(前者 600 秒、后者不回收),活持有者仍然不回收。
|
||||
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
||||
- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
|
||||
- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
|
||||
|
||||
Reference in New Issue
Block a user