修复 AGC 项目写锁残留无法回收与启动失败无诊断
- .agent/project.lock 新增 processStartedAt,PID 存活时核对进程启动身份,身份不一致判定 PID 复用并回收 - 旧锁缺少 processStartedAt 时退回“进程启动时间晚于锁 createdAt 加 5 秒容差”的 PID 复用推断 - 空锁 / 坏锁(崩溃停在 create_new 与落盘之间)宽限期由 600 秒收紧到 30 秒,无法判定持有者存活时仍保持 600 秒 - 新增 StartupLogSlot,配置目录就绪前后都能写 startup.log,startup.*.failed 与启动错误提示不再是死分支 - Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次 - 新增 project_lock_recovery 6 条回归用例:死 PID、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、新鲜空锁不抢锁 - 同步 decision-log 与 App 实施计划文档
This commit is contained in:
@@ -1998,8 +1998,89 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat
|
||||
sanitized.chars().take(2_048).collect()
|
||||
}
|
||||
|
||||
/// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志
|
||||
/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。
|
||||
#[cfg(windows)]
|
||||
fn show_startup_error_dialog(log_path: &Path) {
|
||||
app_log!("Genarrative startup failed; see {}", log_path.display());
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
|
||||
};
|
||||
|
||||
if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let title = std::ffi::OsStr::new("Genarrative AI Game Creator")
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let message_text = format!(
|
||||
"应用启动失败,请把下面的诊断日志发给开发人员:\n{}",
|
||||
log_path.display()
|
||||
);
|
||||
let message = std::ffi::OsStr::new(&message_text)
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
// SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call.
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
std::ptr::null_mut(),
|
||||
message.as_ptr(),
|
||||
title.as_ptr(),
|
||||
MB_OK | MB_ICONERROR | MB_SETFOREGROUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_startup_error_dialog(log_path: &Path) {
|
||||
if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"Genarrative AI Game Creator startup failed; see {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
/// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按
|
||||
/// 标识符推导的 APPDATA 路径,成功后再切换到真实配置目录,保证早期失败也有落点。
|
||||
#[derive(Debug, Default)]
|
||||
struct StartupLogSlot(Mutex<Option<PathBuf>>);
|
||||
|
||||
impl StartupLogSlot {
|
||||
fn new(path: Option<PathBuf>) -> Self {
|
||||
Self(Mutex::new(path))
|
||||
}
|
||||
|
||||
fn set(&self, path: PathBuf) {
|
||||
match self.0.lock() {
|
||||
Ok(mut guard) => *guard = Some(path),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> Option<PathBuf> {
|
||||
match self.0.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&self, line: &str) {
|
||||
if let Some(path) = self.path() {
|
||||
let _ = append_bounded_diagnostic_line(&path, line);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动阶段的致命失败:先落盘,再给出用户可见提示。
|
||||
fn fail(&self, line: &str) {
|
||||
self.append(line);
|
||||
if let Some(path) = self.path() {
|
||||
show_startup_error_dialog(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -2326,8 +2407,18 @@ fn main() {
|
||||
}
|
||||
|
||||
let mut tauri_context = tauri::generate_context!();
|
||||
let startup_log: Option<PathBuf> = None;
|
||||
let setup_log = startup_log.clone();
|
||||
// 配置目录确定之前先按标识符推导 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")
|
||||
}),
|
||||
));
|
||||
let setup_log = Arc::clone(&startup_log);
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -2338,37 +2429,26 @@ fn main() {
|
||||
.manage(ProjectResourcePreviewReadManager::default())
|
||||
.setup(move |app| {
|
||||
error_report::initialize_notifications(app.handle());
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin");
|
||||
}
|
||||
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.as_deref() {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.appdata.configure.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
setup_log.fail(&format!(
|
||||
"startup.appdata.configure.failed details={details}"
|
||||
));
|
||||
}
|
||||
})?;
|
||||
let startup_log = game_creator_runtime_config_dir()
|
||||
.map(|directory| directory.join("diagnostics/startup.log"));
|
||||
if let Some(path) = startup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
|
||||
if let Some(directory) = game_creator_runtime_config_dir() {
|
||||
setup_log.set(directory.join("diagnostics/startup.log"));
|
||||
}
|
||||
setup_log.append("startup.appdata.configure.complete");
|
||||
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
|
||||
let error = std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"客户端 AppData 配置目录未初始化",
|
||||
);
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
"startup.appdata.resolve.failed details=config-dir-uninitialized",
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
}
|
||||
setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized");
|
||||
error
|
||||
})?;
|
||||
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
|
||||
@@ -2377,19 +2457,15 @@ fn main() {
|
||||
format!("加载平台登录态测试 fixture 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin");
|
||||
}
|
||||
setup_log.append("startup.runner.configure.begin");
|
||||
configure_external_agent_runner(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.runner.configure.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.configure.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
@@ -2398,19 +2474,15 @@ fn main() {
|
||||
format!("配置 Agent Runner 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete");
|
||||
}
|
||||
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.as_deref() {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.runner.owner-lock.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
@@ -2421,22 +2493,18 @@ fn main() {
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin");
|
||||
}
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
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.as_deref() {
|
||||
if let Some(path) = setup_log.path() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.runner.attach-owner.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.attach-owner.failed details={details}"
|
||||
));
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
@@ -2445,12 +2513,8 @@ fn main() {
|
||||
format!("绑定 Agent Runner GUI owner 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
|
||||
}
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.setup.complete");
|
||||
}
|
||||
setup_log.append("startup.runner.start.complete");
|
||||
setup_log.append("startup.setup.complete");
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -2615,19 +2679,13 @@ fn main() {
|
||||
.build(tauri_context);
|
||||
let app = match app {
|
||||
Ok(app) => {
|
||||
if let Some(path) = startup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.build.complete");
|
||||
}
|
||||
startup_log.append("startup.build.complete");
|
||||
app
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(path) = startup_log.as_deref() {
|
||||
if let Some(path) = startup_log.path() {
|
||||
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.build.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
startup_log.fail(&format!("startup.build.failed details={details}"));
|
||||
}
|
||||
app_log!("failed to build Genarrative AI Game Creator shell: {error}");
|
||||
std::process::exit(1);
|
||||
@@ -2657,6 +2715,25 @@ mod diagnostic_log_tests {
|
||||
assert!(previous.contains(&"x".repeat(32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_log_slot_keeps_early_failures_after_the_real_config_dir_is_known() {
|
||||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||||
let path = directory.path().join("startup.log");
|
||||
let slot = StartupLogSlot::new(None);
|
||||
|
||||
// 配置目录未知时不能凭空造出日志文件。
|
||||
slot.append("startup.setup.begin");
|
||||
assert!(!path.exists());
|
||||
|
||||
slot.set(path.clone());
|
||||
slot.append("startup.setup.begin");
|
||||
slot.append("startup.appdata.configure.complete");
|
||||
|
||||
let content = fs::read_to_string(&path).expect("read startup log");
|
||||
assert!(content.contains("startup.setup.begin"));
|
||||
assert!(content.contains("startup.appdata.configure.complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,6 +6,12 @@ pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(1);
|
||||
const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600;
|
||||
/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者
|
||||
/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它
|
||||
/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。
|
||||
const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
||||
/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。
|
||||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -103,6 +109,91 @@ fn project_write_lock_process_is_alive(_process_id: u64) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有
|
||||
/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时
|
||||
/// 保持原有的保守回收策略。
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[repr(C)]
|
||||
struct FileTime {
|
||||
low_date_time: u32,
|
||||
high_date_time: u32,
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
|
||||
fn GetProcessTimes(
|
||||
process: *mut c_void,
|
||||
creation_time: *mut FileTime,
|
||||
exit_time: *mut FileTime,
|
||||
kernel_time: *mut FileTime,
|
||||
user_time: *mut FileTime,
|
||||
) -> i32;
|
||||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||||
/// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。
|
||||
const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000;
|
||||
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
|
||||
// SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below.
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
|
||||
if process.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: every FileTime is plain data filled by GetProcessTimes.
|
||||
let mut creation = unsafe { std::mem::zeroed::<FileTime>() };
|
||||
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
|
||||
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
|
||||
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
|
||||
// SAFETY: `process` is a live handle and all four pointers are writable scalars.
|
||||
let result =
|
||||
unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) };
|
||||
// SAFETY: `process` is an owned handle returned by OpenProcess.
|
||||
unsafe { CloseHandle(process) };
|
||||
if result == 0 {
|
||||
return None;
|
||||
}
|
||||
let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time);
|
||||
file_time
|
||||
.checked_sub(FILETIME_UNIX_EPOCH_OFFSET)
|
||||
.map(|unix_100ns| unix_100ns / 10_000_000)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
|
||||
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
|
||||
// SAFETY: sysconf has no memory safety preconditions and returns -1 on failure.
|
||||
let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
|
||||
if clock_ticks <= 0 {
|
||||
return None;
|
||||
}
|
||||
let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?;
|
||||
let start_ticks = stat
|
||||
.rsplit_once(") ")?
|
||||
.1
|
||||
.split_whitespace()
|
||||
.nth(19)?
|
||||
.parse::<u64>()
|
||||
.ok()?;
|
||||
let boot_time = fs::read_to_string("/proc/stat")
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("btime "))?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()?;
|
||||
Some(boot_time + start_ticks / clock_ticks as u64)
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
fn project_write_lock_owner_pid(path: &Path) -> Option<u64> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
@@ -110,26 +201,45 @@ fn project_write_lock_owner_pid(path: &Path) -> Option<u64> {
|
||||
.and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_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| {
|
||||
payload
|
||||
.get("processStartedAt")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
})
|
||||
}
|
||||
|
||||
fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool {
|
||||
project_write_lock_owner_pid(path) == Some(u64::from(std::process::id()))
|
||||
}
|
||||
|
||||
fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 {
|
||||
let created_at = 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));
|
||||
if let Some(created_at) = created_at {
|
||||
return unix_timestamp().saturating_sub(created_at);
|
||||
}
|
||||
fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> u64 {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.elapsed().ok())
|
||||
.map(|elapsed| elapsed.as_secs())
|
||||
.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);
|
||||
}
|
||||
let modified_at = project_write_lock_file_modified_seconds(metadata);
|
||||
unix_timestamp().saturating_sub(modified_at)
|
||||
}
|
||||
|
||||
fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
@@ -141,15 +251,36 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let content = fs::read_to_string(path).ok();
|
||||
let owner_pid = content
|
||||
.as_deref()
|
||||
.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));
|
||||
if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) {
|
||||
return !owner_alive;
|
||||
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) {
|
||||
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) {
|
||||
// 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。
|
||||
(Some(stored), Some(actual)) => stored != actual,
|
||||
// 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用。
|
||||
(None, Some(actual)) => {
|
||||
let lock_created_at = created_at
|
||||
.unwrap_or_else(|| project_write_lock_file_modified_seconds(&metadata));
|
||||
actual
|
||||
> lock_created_at
|
||||
.saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// 无法判定持有者是否存活时保持原有保守策略:只有明显过期才回收。
|
||||
None => {
|
||||
project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS
|
||||
}
|
||||
}
|
||||
project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS
|
||||
}
|
||||
|
||||
fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool {
|
||||
@@ -228,6 +359,10 @@ pub(crate) fn acquire_project_write_lock(
|
||||
let payload = serde_json::json!({
|
||||
"commandId": command_id,
|
||||
"pid": std::process::id(),
|
||||
// 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。
|
||||
"processStartedAt": project_write_lock_process_start_time_seconds(u64::from(
|
||||
std::process::id()
|
||||
)),
|
||||
"createdAt": unix_timestamp(),
|
||||
"nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
});
|
||||
|
||||
@@ -6069,6 +6069,7 @@ mod command_runtime;
|
||||
pub(crate) mod configuration;
|
||||
mod goal;
|
||||
mod project;
|
||||
mod project_lock_recovery;
|
||||
mod project_tools;
|
||||
mod provider;
|
||||
mod response_stream;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use super::*;
|
||||
use std::process::Stdio;
|
||||
|
||||
// Issue #310 复现:异常退出后在项目里残留 `.agent/project.lock`,下一次打开
|
||||
// 项目时所有写操作都被拒绝。
|
||||
//
|
||||
// 下面的用例描述的是期望行为(残留锁必须能被安全回收)。在当前实现下它们会
|
||||
// 失败,用来证明缺陷;修复后应当全部通过,并作为回归用例保留。
|
||||
|
||||
const PROJECT_LOCK_RELATIVE_PATH: &str = ".agent/project.lock";
|
||||
/// 一个确定不会被占用的进程号:Windows 的 OpenProcess 对它返回
|
||||
/// ERROR_INVALID_PARAMETER,Unix 的 kill(pid, 0) 返回 ESRCH。
|
||||
const DEAD_OWNER_PID: u64 = 0xFFFF_FFF0;
|
||||
|
||||
fn write_project_lock_fixture(root: &Path, content: &[u8]) {
|
||||
fs::write(root.join(PROJECT_LOCK_RELATIVE_PATH), content).expect("写入项目写锁 fixture");
|
||||
}
|
||||
|
||||
fn project_lock_fixture_payload(pid: u64, created_at: u64) -> Vec<u8> {
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"commandId": "repro.crashed-writer",
|
||||
"pid": pid,
|
||||
"createdAt": created_at,
|
||||
"nonce": 1,
|
||||
}))
|
||||
.expect("序列化项目写锁 fixture")
|
||||
}
|
||||
|
||||
fn backdate_project_lock_fixture(root: &Path, seconds: u64) {
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(root.join(PROJECT_LOCK_RELATIVE_PATH))
|
||||
.expect("打开项目写锁 fixture");
|
||||
file.set_modified(SystemTime::now() - Duration::from_secs(seconds))
|
||||
.expect("回拨项目写锁 fixture mtime");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_unrelated_live_process() -> std::process::Child {
|
||||
std::process::Command::new("ping")
|
||||
.args(["-n", "30", "127.0.0.1"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("启动无关的活进程")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_unrelated_live_process() -> std::process::Child {
|
||||
std::process::Command::new("sleep")
|
||||
.arg("30")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("启动无关的活进程")
|
||||
}
|
||||
|
||||
fn stop_unrelated_live_process(mut child: std::process::Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// 基线:记录着已死进程号的残留锁本来就应该被回收。
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_dead_owner_pid() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-dead-owner", "锁回收-死进程").expect("初始化项目");
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&project_lock_fixture_payload(DEAD_OWNER_PID, unix_timestamp()),
|
||||
);
|
||||
|
||||
let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash");
|
||||
assert!(
|
||||
acquired.is_ok(),
|
||||
"死进程残留锁未被回收,实际错误:{:?}",
|
||||
acquired.err()
|
||||
);
|
||||
drop(acquired);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 A:崩溃发生在 create_new 成功、payload 写盘之前,留下 0 字节锁。
|
||||
/// 现在要等满 600 秒才会回收,重启后 10 分钟内所有写操作都失败。
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_empty_body_after_short_grace() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-empty-body", "锁回收-空锁").expect("初始化项目");
|
||||
write_project_lock_fixture(&root, b"");
|
||||
backdate_project_lock_fixture(&root, 120);
|
||||
|
||||
let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash");
|
||||
assert!(
|
||||
acquired.is_ok(),
|
||||
"空残留锁超过宽限期仍未被回收,实际错误:{:?}",
|
||||
acquired.err()
|
||||
);
|
||||
drop(acquired);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 B:崩溃后 Windows 把同一个 PID 复用给了另一个无关进程。
|
||||
/// 只要那个进程还活着,残留锁就永远不会被回收。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_pid_reused_by_other_live_process() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-pid-reuse", "锁回收-PID复用").expect("初始化项目");
|
||||
|
||||
let unrelated = spawn_unrelated_live_process();
|
||||
// 锁是在一小时前被写下的,而记录里的 PID 现在属于刚刚才启动的另一个进程:
|
||||
// 这只能是 PID 复用,原持有者早已退出。
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&project_lock_fixture_payload(u64::from(unrelated.id()), unix_timestamp() - 3_600),
|
||||
);
|
||||
|
||||
let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash");
|
||||
stop_unrelated_live_process(unrelated);
|
||||
assert!(
|
||||
acquired.is_ok(),
|
||||
"PID 复用后的残留锁未被回收,实际错误:{:?}",
|
||||
acquired.err()
|
||||
);
|
||||
drop(acquired);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 复现 C:新锁自带进程启动身份。即使时间戳看起来“刚刚写过”,只要身份对不上
|
||||
/// 就说明是 PID 复用,必须回收;这条路径不依赖系统时钟是否发生跳变。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_reclaims_pid_reused_identity_mismatch() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-pid-identity", "锁回收-身份不一致")
|
||||
.expect("初始化项目");
|
||||
let unrelated = spawn_unrelated_live_process();
|
||||
let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id()))
|
||||
.expect("读取活进程启动时间");
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"commandId": "repro.crashed-writer",
|
||||
"pid": u64::from(unrelated.id()),
|
||||
"processStartedAt": started_at + 1,
|
||||
"createdAt": unix_timestamp(),
|
||||
"nonce": 1,
|
||||
}))
|
||||
.expect("序列化项目写锁 fixture"),
|
||||
);
|
||||
|
||||
let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash");
|
||||
stop_unrelated_live_process(unrelated);
|
||||
assert!(
|
||||
acquired.is_ok(),
|
||||
"启动身份不一致的残留锁未被回收,实际错误:{:?}",
|
||||
acquired.err()
|
||||
);
|
||||
drop(acquired);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:PID 与启动身份都吻合说明持有者真的活着,绝不能抢锁。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_keeps_matching_process_identity() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-live-identity", "锁回收-身份一致").expect("初始化项目");
|
||||
let unrelated = spawn_unrelated_live_process();
|
||||
let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id()))
|
||||
.expect("读取活进程启动时间");
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"commandId": "repro.live-writer",
|
||||
"pid": u64::from(unrelated.id()),
|
||||
"processStartedAt": started_at,
|
||||
"createdAt": unix_timestamp(),
|
||||
"nonce": 1,
|
||||
}))
|
||||
.expect("序列化项目写锁 fixture"),
|
||||
);
|
||||
|
||||
let error = acquire_project_write_lock(&root, "repro.acquire-concurrent")
|
||||
.expect_err("持有者仍然活着时不能回收");
|
||||
stop_unrelated_live_process(unrelated);
|
||||
assert!(
|
||||
error.contains("项目正在被其他写操作占用"),
|
||||
"活持有者必须进入占用分支,实际错误:{error}"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 护栏:刚创建的空锁可能只是写入方还没落盘,绝不能被别人抢走。
|
||||
#[test]
|
||||
fn project_write_lock_keeps_fresh_empty_body() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "lock-fresh-empty", "锁回收-新鲜空锁").expect("初始化项目");
|
||||
write_project_lock_fixture(&root, b"");
|
||||
|
||||
let error = acquire_project_write_lock(&root, "repro.acquire-concurrent")
|
||||
.expect_err("刚创建的空锁必须保持占用");
|
||||
assert!(
|
||||
error.contains("项目正在被其他写操作占用"),
|
||||
"并发写必须进入占用分支,实际错误:{error}"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
@@ -8188,3 +8188,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
|
||||
- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
|
||||
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
|
||||
|
||||
## 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-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
|
||||
- 验证:`project_lock_recovery` 6 条与 `diagnostic_log` 5 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
|
||||
|
||||
@@ -1330,3 +1330,9 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
|
||||
- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。
|
||||
- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。
|
||||
|
||||
## 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` 并弹出可见提示。
|
||||
|
||||
Reference in New Issue
Block a user