收紧资源布局 revision IPC 精度边界

统一限制资源布局 revision 到 JavaScript 最大安全整数
在 Rust serde、Tauri 输入与前端 IPC 三层拒绝非安全 revision
补充最大值往返、上限失败、超限零副作用与不可信响应回归
同步更新资源画布合同、技术决策与竞态经验
This commit is contained in:
2026-07-31 11:27:23 +08:00
parent 0c817383b8
commit beec99a3b7
10 changed files with 283 additions and 18 deletions
@@ -1,4 +1,8 @@
use super::*;
use shared_contracts::game_creation_app::{
validate_project_resource_canvas_layout_revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
};
use std::collections::HashSet;
use std::fs::File;
use std::sync::{Mutex, OnceLock};
@@ -356,6 +360,7 @@ fn validate_resource_layout(
if layout.mode != mode {
return Err("资源布局 mode 与文件不匹配".to_string());
}
validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?;
validate_resource_layout_positions(&layout.positions)
}
@@ -436,6 +441,7 @@ pub(crate) fn update_project_resource_canvas_layout_at(
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?;
validate_resource_layout_positions(&positions)?;
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
@@ -464,10 +470,10 @@ pub(crate) fn update_project_resource_canvas_layout_at(
layout: current,
});
}
let next_revision = current
.revision
.checked_add(1)
.ok_or_else(|| "资源布局 revision 已达到上限,无法继续保存".to_string())?;
if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string());
}
let next_revision = current.revision + 1;
if current_resource_layout_project_id(root)? != project_id {
return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string());
}
@@ -703,7 +709,7 @@ mod tests {
}
#[test]
fn resource_layout_revision_exhaustion_fails_without_overwrite() {
fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限")
.expect("init project");
@@ -715,7 +721,7 @@ mod tests {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "layout-revision-max".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: u64::MAX,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![layout_position("asset-a", 10)],
updated_at: 1,
};
@@ -726,7 +732,7 @@ mod tests {
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-revision-max",
u64::MAX,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
vec![layout_position("asset-a", 999)],
)
.expect_err("revision exhaustion must fail");
@@ -735,6 +741,48 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_unsafe_revision_without_side_effects() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision")
.expect("init project");
let expected_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-unsafe-revision",
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
vec![layout_position("asset-a", 10)],
)
.expect_err("unsafe expected revision must fail");
assert!(expected_error.contains("revision"));
assert!(!root.join(".agent/workbench").exists());
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
let unsafe_payload = serde_json::json!({
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
"projectId": "layout-unsafe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 1
});
let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture");
fs::write(&path, &original).expect("write unsafe fixture");
let read_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("unsafe persisted revision must fail");
assert!(read_error.contains("revision") || read_error.contains("安全整数"));
assert_eq!(fs::read(&path).expect("read unsafe fixture"), original);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() {
let root = unique_resource_layout_project_path();
@@ -6,6 +6,7 @@ import type {
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
@@ -277,6 +278,19 @@ export function useProjectResourceCanvasLayout({
return;
}
const expectedRevision = persistedLayoutRef.current.revision;
if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
setNotice(
intent.kind === 'resources'
? '布局保存失败,已保留当前会话布局'
: '布局保存失败,已恢复上次布局',
);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
activeWriteIntentRef.current = intent;
if (mountedRef.current) {
setSaving(true);
@@ -287,7 +301,7 @@ export function useProjectResourceCanvasLayout({
projectPath: scope.projectPath,
expectedProjectId: scope.projectId,
mode: scope.mode,
expectedRevision: persistedLayoutRef.current.revision,
expectedRevision,
positions: candidate.positions,
},
)
@@ -296,6 +310,11 @@ export function useProjectResourceCanvasLayout({
if (currentScope.epoch !== intent.scopeEpoch) {
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision)
) {
throw new Error('layout response revision is not a safe integer');
}
if (!layoutMatchesScope(result.layout, currentScope)) {
throw new Error('layout response scope mismatch');
}
@@ -422,11 +441,13 @@ export function useProjectResourceCanvasLayout({
{ projectPath, mode },
)
.then((loaded) => {
if (
cancelled ||
scopeRef.current.epoch !== epoch ||
!layoutMatchesScope(loaded, scope)
) {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) {
throw new Error('layout response revision is not a safe integer');
}
if (!layoutMatchesScope(loaded, scope)) {
return;
}
persistedLayoutRef.current = loaded;
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../packages/shared/src/contracts/gameCreationApp';
describe('resource canvas layout contract', () => {
it('keeps revisions inside the JSON safe integer range', () => {
const maxRevision = GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION;
expect(JSON.parse(JSON.stringify({ revision: maxRevision }))).toEqual({
revision: maxRevision,
});
expect(isSafeProjectResourceCanvasLayoutRevision(0)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision + 1)).toBe(
false,
);
expect(isSafeProjectResourceCanvasLayoutRevision(-1)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(1.5)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(Number.NaN)).toBe(false);
expect(
isSafeProjectResourceCanvasLayoutRevision(Number.POSITIVE_INFINITY),
).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision('1')).toBe(false);
});
});
@@ -8,6 +8,7 @@ import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
import {
createResourceSignature,
@@ -70,6 +71,84 @@ describe('useProjectResourceCanvasLayout', () => {
);
});
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
[position('resource-a', 10, 20)],
);
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() =>
expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'),
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(0);
});
it('rejects an unsafe revision from an update response and restores the trusted layout', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() =>
expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'),
);
expect(result.current.layout.positions[0]).toMatchObject({ x: 10, y: 20 });
expect(result.current.saving).toBe(false);
});
it('uses the latest resource snapshot when the initial scope read resolves', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
@@ -178,7 +178,7 @@ type ProjectResourceCanvasLayout = {
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991``Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB``resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`
@@ -229,7 +229,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方只提交当前已读取布局的 `expectedProjectId` 身份栅栏,不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在、manifest 有效且 projectId 与栅栏一致,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。路径被其它窗口重建为新项目时,旧窗口必须在任何布局副作用前失败。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。
- `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。
- revision 使用 `u64` 且每次成功必须严格递增;当前值已经是 `u64::MAX` 时失败关闭并保持原文件不变,不得饱和后继续以相同 revision 返回成功
- Rust 内部 revision 使用 `u64`,但 JSON / Tauri 合同统一限制为 `0..=9_007_199_254_740_991`,每次成功必须严格递增;持久值或 `expectedRevision` 超限时必须拒绝,当前值达到上限时失败关闭并保持原文件字节不变,不得把超出 JavaScript 安全整数范围的值返回前端或用于 CAS
- 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。
#### 5.2.4 前端布局与协调合同
@@ -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 被自动覆盖。type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序,manifest 资产使用 `asset.kind`,任务产物、附件与 Agent 文本成果使用稳定 fallback,subtype 同时进入资源协调签名。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;revision 使用 checked increment,耗尽时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;Rust 内部 revision 保留 `u64`,但共享 serde、Tauri 输入和前端 IPC 统一限制为 `0..=Number.MAX_SAFE_INTEGER`,达到上限时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 业务边界:布局是本地工作台 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`
@@ -3888,6 +3888,14 @@
- 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
## Rust u64 revision 不能直接穿过 JavaScript number 边界(2026-07-31
- 现象:资源布局 sidecar 的 revision 在 Rust 中可增长到完整 `u64`,但经 JSON / Tauri 返回 TypeScript 后只能用 `number` 表示;超过 `9_007_199_254_740_991` 时相邻整数会折叠为同一值,窗口可能持续 conflict,甚至用失真的 expectedRevision 破坏 CAS 判等语义。
- 原因:Rust 的 `checked_add` 只防止 `u64` 溢出,不能证明序列化后的整数仍能被 JavaScript 精确表示;纯 Rust `u64::MAX` 测试没有经过真实跨 JSON 合同。
- 处理:保留 Rust `u64` 存储类型,但把共享合同合法域冻结为 `0..=Number.MAX_SAFE_INTEGER`。共享 DTO 对 revision 自定义 serde 校验,Tauri 更新在任何项目或锁副作用前验证 expectedRevisionsidecar 读取拒绝超限值,前端在 IPC 读取、更新响应和请求发送前重复验证非负安全整数;达到上限时写入失败且 sidecar 字节不变。
- 验证:Rust 与 TypeScript 合同测试分别覆盖最大安全值往返、最大值加一拒绝;Tauri 持久层覆盖超限 expectedRevision 零 workbench 副作用、超限 sidecar 原字节保留和最大安全值递增失败;Hook 覆盖不可信读写响应不能进入 CAS。
- 关联:`server-rs/crates/shared-contracts/src/game_creation_app.rs``packages/shared/src/contracts/gameCreationApp.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`
## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI
- 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。
@@ -335,7 +335,7 @@ game-project/
2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下:
- 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 加一,冲突返回最新完整布局且不写文件。
- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。revision 虽在 Rust 中使用 `u64`,但跨 JSON / Tauri / TypeScript 的合法域固定为 `0..=Number.MAX_SAFE_INTEGER`;共享 DTO 序列化与反序列化、Tauri 输入和前端 IPC 响应均执行同一边界校验。
- 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 画布范围与滚动访问,不裁切持久坐标。
@@ -347,7 +347,7 @@ game-project/
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 使用 checked increment`u64::MAX` 时保持原文件并失败关闭,不能让饱和值击穿 CAS。
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。
## 分阶段实施
@@ -469,6 +469,18 @@ export interface GameCreationAppAssetManifestEntry {
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
'game-creator-resource-layout.v1' as const;
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991;
export function isSafeProjectResourceCanvasLayoutRevision(
value: unknown,
): value is number {
return (
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0 &&
value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
}
export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type';
@@ -488,6 +488,37 @@ pub struct GameCreationAppAssetManifestEntry {
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
pub const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
pub fn validate_project_resource_canvas_layout_revision(revision: u64) -> Result<(), &'static str> {
if revision > GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 超出 JavaScript 安全整数范围");
}
Ok(())
}
fn serialize_project_resource_canvas_layout_revision<S>(
revision: &u64,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
validate_project_resource_canvas_layout_revision(*revision)
.map_err(serde::ser::Error::custom)?;
serializer.serialize_u64(*revision)
}
fn deserialize_project_resource_canvas_layout_revision<'de, D>(
deserializer: D,
) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let revision = u64::deserialize(deserializer)?;
validate_project_resource_canvas_layout_revision(revision).map_err(serde::de::Error::custom)?;
Ok(revision)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -521,6 +552,10 @@ pub struct ProjectResourceCanvasLayout {
pub schema_version: String,
pub project_id: String,
pub mode: ProjectResourceCanvasLayoutMode,
#[serde(
deserialize_with = "deserialize_project_resource_canvas_layout_revision",
serialize_with = "serialize_project_resource_canvas_layout_revision"
)]
pub revision: u64,
pub positions: Vec<ProjectResourceCanvasPosition>,
pub updated_at: u64,
@@ -1535,4 +1570,39 @@ mod tests {
})
);
}
#[test]
fn resource_canvas_layout_revision_rejects_json_outside_js_safe_integer_range() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-safe-revision".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![],
updated_at: 123,
};
let serialized = serde_json::to_vec(&layout).expect("safe revision should serialize");
let decoded: ProjectResourceCanvasLayout =
serde_json::from_slice(&serialized).expect("safe revision should deserialize");
assert_eq!(
decoded.revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
let unsafe_layout = ProjectResourceCanvasLayout {
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
..layout
};
assert!(serde_json::to_value(unsafe_layout).is_err());
let unsafe_payload = json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-safe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 123
});
assert!(serde_json::from_value::<ProjectResourceCanvasLayout>(unsafe_payload).is_err());
}
}