修复资源画布系统锁与无效路径副作用
以 Unix flock 和 Windows 独占句柄替换 mtime stale 锁回收 持久锁文件只随句柄释放,保留安全路径、owner、硬链接与权限校验 在创建 workbench 前验证 manifest,并在锁内复核 projectId 补充活锁老化、并发 CAS、无效根零副作用测试及契约文档
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts";
|
||||
const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock";
|
||||
@@ -9,20 +11,12 @@ const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
|
||||
const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
|
||||
const RESOURCE_LAYOUT_LOCK_STALE_AFTER_SECONDS: u64 = 600;
|
||||
|
||||
static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ResourceLayoutWriteLock {
|
||||
path: PathBuf,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Drop for ResourceLayoutWriteLock {
|
||||
fn drop(&mut self) {
|
||||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.token) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
_file: File,
|
||||
}
|
||||
|
||||
fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str {
|
||||
@@ -39,85 +33,251 @@ fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> Strin
|
||||
)
|
||||
}
|
||||
|
||||
fn resource_layout_lock_is_stale(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 4096 {
|
||||
return false;
|
||||
}
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.elapsed().ok())
|
||||
.is_some_and(|elapsed| elapsed.as_secs() > RESOURCE_LAYOUT_LOCK_STALE_AFTER_SECONDS)
|
||||
}
|
||||
|
||||
fn acquire_resource_layout_write_lock(root: &Path) -> Result<ResourceLayoutWriteLock, String> {
|
||||
validate_project_root(root)?;
|
||||
let mut path = resolve_local_project_path(root, RESOURCE_LAYOUT_LOCK_PATH)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("创建资源布局锁目录失败:{}: {error}", parent.display()))?;
|
||||
}
|
||||
path = resolve_local_project_path(root, RESOURCE_LAYOUT_LOCK_PATH)?;
|
||||
let token = format!(
|
||||
"{}:{}:{}",
|
||||
std::process::id(),
|
||||
unix_millis(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let mut reclaimed = false;
|
||||
for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
options.mode(0o600);
|
||||
if let Some(file) = try_open_resource_layout_write_lock_file(root)? {
|
||||
return Ok(ResourceLayoutWriteLock { _file: file });
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(token.as_bytes())
|
||||
.and_then(|_| file.sync_data())
|
||||
.map_err(|error| {
|
||||
let _ = fs::remove_file(&path);
|
||||
format!("写入资源布局锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
return Ok(ResourceLayoutWriteLock {
|
||||
path,
|
||||
token: token.clone(),
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists
|
||||
&& !reclaimed
|
||||
&& resource_layout_lock_is_stale(&path) =>
|
||||
{
|
||||
fs::remove_file(&path).map_err(|error| {
|
||||
format!("清理失效资源布局锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
reclaimed = true;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
|
||||
std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS));
|
||||
continue;
|
||||
}
|
||||
return Err("资源布局正在被其他窗口保存,请稍后重试".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!("创建资源布局锁失败:{}: {error}", path.display()));
|
||||
}
|
||||
if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
|
||||
std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS));
|
||||
}
|
||||
}
|
||||
Err("资源布局正在被其他窗口保存,请稍后重试".to_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||||
use std::ffi::CString;
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
|
||||
validate_project_root(root)?;
|
||||
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
|
||||
let path = root.join(&relative_path);
|
||||
let mut components = relative_path.split('/').collect::<Vec<_>>();
|
||||
let file_name = components
|
||||
.pop()
|
||||
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
|
||||
let mut directory = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW)
|
||||
.open(root)
|
||||
.map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?;
|
||||
for component in components {
|
||||
let component =
|
||||
CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?;
|
||||
// SAFETY: `directory` is a live directory fd and `component` is NUL terminated.
|
||||
let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) };
|
||||
if created != 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.kind() != std::io::ErrorKind::AlreadyExists {
|
||||
return Err(format!(
|
||||
"创建资源布局锁目录失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
// SAFETY: `directory` and `component` remain valid for the duration of openat.
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
component.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(format!(
|
||||
"安全打开资源布局锁目录失败:{}: {}",
|
||||
path.display(),
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
// SAFETY: openat returned a new owned fd.
|
||||
directory = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?;
|
||||
// SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated.
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
file_name.as_ptr(),
|
||||
libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(format!(
|
||||
"安全打开资源布局锁失败:{}: {}",
|
||||
path.display(),
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
// SAFETY: openat returned a new owned fd.
|
||||
let file = unsafe { File::from_raw_fd(fd) };
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?;
|
||||
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
||||
let effective_user_id = unsafe { libc::geteuid() };
|
||||
if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 {
|
||||
return Err(format!(
|
||||
"资源布局锁必须是当前用户持有的无硬链接普通文件:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?;
|
||||
let path_metadata = fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?;
|
||||
if path_metadata.file_type().is_symlink()
|
||||
|| path_metadata.dev() != metadata.dev()
|
||||
|| path_metadata.ino() != metadata.ino()
|
||||
{
|
||||
return Err(format!(
|
||||
"资源布局锁路径在安全打开期间发生替换:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let verified = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?;
|
||||
if verified.uid() != effective_user_id
|
||||
|| verified.nlink() != 1
|
||||
|| verified.permissions().mode() & 0o777 != 0o600
|
||||
{
|
||||
return Err(format!(
|
||||
"资源布局锁必须由当前用户持有且权限为 0600:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
// SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock.
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
|
||||
return Ok(Some(file));
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(format!(
|
||||
"获取资源布局系统文件锁失败:{}: {error}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||||
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003;
|
||||
|
||||
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
|
||||
validate_project_root(root)?;
|
||||
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
|
||||
let path = root.join(&relative_path);
|
||||
let mut components = relative_path.split('/').collect::<Vec<_>>();
|
||||
components
|
||||
.pop()
|
||||
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
|
||||
let mut guarded_directories = Vec::with_capacity(components.len() + 1);
|
||||
let mut current = root.to_path_buf();
|
||||
for component in std::iter::once(None).chain(components.into_iter().map(Some)) {
|
||||
if let Some(component) = component {
|
||||
current.push(component);
|
||||
if !current.exists() {
|
||||
fs::create_dir(¤t).map_err(|error| {
|
||||
format!("创建资源布局锁目录失败:{}: {error}", current.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let metadata = fs::symlink_metadata(¤t).map_err(|error| {
|
||||
format!(
|
||||
"读取资源布局锁目录元数据失败:{}: {error}",
|
||||
current.display()
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(format!(
|
||||
"资源布局锁目录必须是普通目录且不能是 reparse point:{}",
|
||||
current.display()
|
||||
));
|
||||
}
|
||||
let handle = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ_WRITE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(¤t)
|
||||
.map_err(|error| {
|
||||
format!("安全打开资源布局锁目录失败:{}: {error}", current.display())
|
||||
})?;
|
||||
let opened = handle
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?;
|
||||
if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(format!(
|
||||
"资源布局锁目录打开后身份无效:{}",
|
||||
current.display()
|
||||
));
|
||||
}
|
||||
guarded_directories.push(handle);
|
||||
}
|
||||
if let Ok(metadata) = fs::symlink_metadata(&path) {
|
||||
if metadata.file_type().is_symlink()
|
||||
|| !metadata.is_file()
|
||||
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
{
|
||||
return Err(format!(
|
||||
"资源布局锁必须是普通文件且不能是 reparse point:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
match fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.share_mode(0)
|
||||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(file) => {
|
||||
validate_windows_regular_file_handle(&file, "资源布局锁")?;
|
||||
crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?;
|
||||
Ok(Some(file))
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"获取资源布局系统文件锁失败:{}: {error}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||||
Err(format!(
|
||||
"当前平台不支持资源布局系统文件锁:{}",
|
||||
root.join(RESOURCE_LAYOUT_LOCK_PATH).display()
|
||||
))
|
||||
}
|
||||
|
||||
fn current_resource_layout_project_id(root: &Path) -> Result<String, String> {
|
||||
validate_project_root(root)?;
|
||||
let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?;
|
||||
@@ -276,8 +436,12 @@ pub(crate) fn update_project_resource_canvas_layout_at(
|
||||
positions: Vec<ProjectResourceCanvasPosition>,
|
||||
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
|
||||
validate_resource_layout_positions(&positions)?;
|
||||
let preflight_project_id = current_resource_layout_project_id(root)?;
|
||||
let _lock = acquire_resource_layout_write_lock(root)?;
|
||||
let project_id = current_resource_layout_project_id(root)?;
|
||||
if project_id != preflight_project_id {
|
||||
return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string());
|
||||
}
|
||||
let relative_path = resource_layout_relative_path(mode);
|
||||
let current = match read_resource_layout_sidecar(root, &relative_path)? {
|
||||
Some(layout) => {
|
||||
@@ -379,6 +543,83 @@ mod tests {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_layout_invalid_roots_have_zero_workbench_side_effects() {
|
||||
let missing_root = unique_resource_layout_project_path();
|
||||
let missing_error = update_project_resource_canvas_layout_at(
|
||||
&missing_root,
|
||||
ProjectResourceCanvasLayoutMode::Dependency,
|
||||
0,
|
||||
vec![layout_position("asset-a", 10)],
|
||||
)
|
||||
.expect_err("missing project root must fail");
|
||||
assert!(missing_error.contains("manifest") || missing_error.contains("项目"));
|
||||
assert!(!missing_root.exists());
|
||||
|
||||
let non_project_root = unique_resource_layout_project_path();
|
||||
fs::create_dir_all(&non_project_root).expect("create non-project root");
|
||||
update_project_resource_canvas_layout_at(
|
||||
&non_project_root,
|
||||
ProjectResourceCanvasLayoutMode::Dependency,
|
||||
0,
|
||||
vec![layout_position("asset-a", 10)],
|
||||
)
|
||||
.expect_err("non-project root must fail");
|
||||
assert!(!non_project_root.join(".agent").exists());
|
||||
fs::remove_dir_all(&non_project_root).ok();
|
||||
|
||||
let invalid_manifest_root = unique_resource_layout_project_path();
|
||||
let agent_directory = invalid_manifest_root.join(".agent");
|
||||
fs::create_dir_all(&agent_directory).expect("create invalid project agent directory");
|
||||
fs::write(agent_directory.join("manifest.json"), b"{invalid-json")
|
||||
.expect("write invalid manifest");
|
||||
update_project_resource_canvas_layout_at(
|
||||
&invalid_manifest_root,
|
||||
ProjectResourceCanvasLayoutMode::Dependency,
|
||||
0,
|
||||
vec![layout_position("asset-a", 10)],
|
||||
)
|
||||
.expect_err("invalid project manifest must fail");
|
||||
assert!(!agent_directory.join("workbench").exists());
|
||||
fs::remove_dir_all(invalid_manifest_root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() {
|
||||
use std::fs::FileTimes;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let root = unique_resource_layout_project_path();
|
||||
init_local_game_project_at(&root, "layout-system-lock", "布局系统锁")
|
||||
.expect("init project");
|
||||
let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock");
|
||||
let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH);
|
||||
let original = fs::symlink_metadata(&lock_path).expect("stat original lock");
|
||||
first
|
||||
._file
|
||||
.set_times(FileTimes::new().set_modified(UNIX_EPOCH))
|
||||
.expect("age live lock mtime");
|
||||
|
||||
assert!(try_open_resource_layout_write_lock_file(&root)
|
||||
.expect("try competing lock")
|
||||
.is_none());
|
||||
let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock");
|
||||
assert_eq!(while_locked.dev(), original.dev());
|
||||
assert_eq!(while_locked.ino(), original.ino());
|
||||
|
||||
drop(first);
|
||||
let second = try_open_resource_layout_write_lock_file(&root)
|
||||
.expect("try released lock")
|
||||
.expect("released system lock must be acquirable");
|
||||
let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock");
|
||||
assert_eq!(after_reacquire.dev(), original.dev());
|
||||
assert_eq!(after_reacquire.ino(), original.ino());
|
||||
drop(second);
|
||||
assert!(lock_path.exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_layout_compare_and_swap_returns_latest_without_overwrite() {
|
||||
let root = unique_resource_layout_project_path();
|
||||
|
||||
@@ -195,7 +195,7 @@ type ProjectResourceCanvasLayout = {
|
||||
|
||||
- 文件名必须与 payload 的 `mode` 一致;payload 的 `projectId` 必须与当前 `.agent/manifest.json` 一致。
|
||||
- 布局只属于工作台 UI 状态,不写入 manifest,不增加游戏项目 mutation revision,不使 Runtime verification 失效,不触发权限确认,也不作为 Agent 产物、资产或 Git 提交依据。
|
||||
- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用写锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。
|
||||
- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用系统文件锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。`.layout.lock` 是持久锁入口,Unix 互斥跟随 `flock` 文件描述符,Windows 互斥跟随不共享的文件句柄;进程退出由操作系统释放,应用不得按 mtime、PID 文本或其它 stale 启发式删除锁文件。
|
||||
- 主文件损坏、schema 不支持、身份不匹配、文件超限或安全文件检查失败时必须失败关闭,不得把默认空布局覆盖到原文件。若原子安装留下可验证的恢复副本,读取时可按既有 sidecar 恢复规则恢复后再返回。
|
||||
- 本切片不把布局同步到 `api-server`、SpacetimeDB、云端账号或其它设备。
|
||||
|
||||
@@ -226,7 +226,7 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
```
|
||||
|
||||
- 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。
|
||||
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 在锁内从 manifest 取得 `projectId`,重新读取当前布局,并生成新的 revision 与时间戳。
|
||||
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在且 manifest 有效,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。
|
||||
- `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。
|
||||
- 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
|
||||
- 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。
|
||||
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
|
||||
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
|
||||
- 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。
|
||||
- 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。
|
||||
- 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -3862,3 +3862,11 @@
|
||||
- 根因:旧实现调用 `start_local_game_preview` 前就把“项目 + parent run”的授权加入 attempted 集合并清空;首版完成投影与后续专业任务仍在写项目时,启动恰逢项目写锁竞争,catch 只显示错误却无法重试。
|
||||
- 约束:一次性语义应按“成功或确定性终态”消费,不按“函数调用次数”消费。项目写锁竞争保留同一授权并轮询重试;成功、显式 deny 与非瞬时失败才清除。授权需持久化项目路径和 accepted runId,重启恢复时仍必须逐项匹配,切换项目不得继承。
|
||||
- 回归:AppSurface 模拟第一次 `start_local_game_preview` 返回 `项目正在被其他写操作占用`、第二次成功,断言最终渲染游戏区域且启动调用恰为两次;完整 AppSurface 仍需覆盖显式 deny、停止隐藏与项目切换隔离。
|
||||
|
||||
## 跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30)
|
||||
|
||||
- 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`。
|
||||
- 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。
|
||||
- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`,Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse point,Unix 还核对 owner、硬链接数、inode 和 `0600`。更新先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根和损坏 manifest 都不能创建 workbench。
|
||||
- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,以及三类无效根零 workbench 副作用。锁等待超时只能返回可重试错误,不得转为 stale 删除。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。
|
||||
|
||||
@@ -336,7 +336,7 @@ game-project/
|
||||
|
||||
- dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。
|
||||
- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。
|
||||
- Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令在资源布局专用跨窗口锁内重新读取 manifest 和当前 sidecar,复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
|
||||
- Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
|
||||
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。
|
||||
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
|
||||
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
|
||||
@@ -346,6 +346,8 @@ game-project/
|
||||
|
||||
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
|
||||
|
||||
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令在任何目录创建前先读取 manifest,锁内再次核对 projectId;不存在根、非项目根和损坏 manifest 的回归测试均确认不产生 `.agent/workbench`。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。
|
||||
|
||||
Reference in New Issue
Block a user