完美像素孤儿占位判据由结构不变式改为有界时间窗
原判据依赖「活着的占位始终在内存里、永不经过 hydrate」,在单标签页下成立, 多标签页下是假的:B 标签会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以当前 revision 合法写回把它删掉——CAS 挡不住,因为 B 不陈旧。 改为只有超过 180 秒才判定为孤儿。窗口由两侧封死:服务端最坏 30 + 60 = 90 秒 (timeout_at 强制),客户端整个 POST 被 120 秒封顶,之后必已 abort 并把占位 改成 failed 或移除。取 120 + 60 余量。 这个方案在持久化预算落地之前不成立——那时服务端最坏时长无界,任何时间窗都是 拍脑袋。时间戳复用 generationStartedAt,已自动打戳、已序列化、已 hydrate。 同时撤回此前多次记录的「加会话归属标识」方案:B 拿到不同的会话 id 推不出 A 是死是活,只能识别「不是我的」,不能识别「已经没人要了」。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6038,3 +6038,17 @@
|
||||
- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
|
||||
- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素孤儿占位判据由结构不变式改为有界时间窗
|
||||
|
||||
- 缺陷:`dropDeadInlineGenerationPlaceholders` 原先依赖一条结构性不变式——置位 `requiresLiveSession` 的占位其收口只能由创建它的会话完成,而活着的那份始终在内存里、永不经过 hydrate,所以从服务端快照读回来的必然属于已死会话。这条在单标签页下成立,多标签页下是假的:B 标签打开同一项目会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以**当前** revision 合法写回,把 A 的占位删掉。
|
||||
- CAS 的作用要说准:它挡的是基于陈旧 revision 的覆盖写。B 在 A 完成**前**写入时 revision 是当前的,CAS 放行——这是有害的那一半;B 在 A 完成**后**写入时 revision 已陈旧,CAS 拒绝——所以「B 把 A 的成品图层写没」这种更严重的情况本来就不会发生。此前 decision-log 笼统写「CAS 兜底」是错的,纠正后也不应反过来说 CAS 完全无用。
|
||||
- 决策:判据改为有界时间窗,只有超过 180 秒才判定为孤儿。窗口上界由两侧共同封死——服务端最坏合法时长是处理 30 秒加持久化 60 秒(都由 `timeout_at` 强制,见同日持久化预算条目),客户端整个 POST 又被 120 秒超时封顶;120 秒之后客户端必已 abort 并把占位改成 `failed` 或移除。取 180 = 120 客户端上限 + 60 余量(网络往返、标签页挂起后的时钟漂移)。
|
||||
- 关键依赖:这个方案在持久化预算落地**之前**不成立。那时服务端最坏时长无界,任何时间窗都是拍脑袋;把最坏时长收进 90 秒之后,时间窗才有硬依据。
|
||||
- 复用既有字段:时间戳用 `generationStartedAt`,由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,已序列化、已 hydrate,无需新增字段。
|
||||
- 撤回先前方案:此前多次记录「真正的修法是给占位加会话归属标识、只允许创建者剥离」。该方案不成立——B 拿到一个不同的会话 id,推不出 A 是死是活,照样只能猜。会话 id 只能识别「不是我的」,不能识别「已经没人要了」。
|
||||
- 兜底方向:缺 `generationStartedAt` 时按可剥离处理。实践中不会出现(两个字段同一次创建一起写),但按「保留」会让这类占位永久留在画布上,按「剥离」最坏只是退回引入时间窗之前的行为。
|
||||
- 时钟:取读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。
|
||||
- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。
|
||||
- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -1036,6 +1036,83 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(result.project.layers).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a live-session placeholder that is still inside the live window', () => {
|
||||
// 中文注释:这是多标签页的核心场景。B 标签打开同一项目时会 hydrate 到 A 标签正在用的
|
||||
// 活占位——原先的结构性判据(「从服务端读回来的必然属于已死会话」)在这里是假的,会被
|
||||
// 当成孤儿剥离,再由 B 下一次布局保存以当前 revision 合法写回,把 A 的占位删掉。
|
||||
const now = 1_700_000_000_000;
|
||||
const project = buildProject([
|
||||
buildDialogItem('live', {
|
||||
status: 'generating',
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: now - 30_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
||||
|
||||
expect(result.droppedCount).toBe(0);
|
||||
expect(result.project).toBe(project);
|
||||
});
|
||||
|
||||
it('keeps a live-session placeholder right up to the window boundary', () => {
|
||||
// 中文注释:窗口是「服务端最坏 90 秒 + 客户端 120 秒上限 + 余量」推出来的,边界必须是
|
||||
// 包含式:正好 180 秒时操作仍可能刚刚收口,不能剥。
|
||||
const now = 1_700_000_000_000;
|
||||
const project = buildProject([
|
||||
buildDialogItem('boundary', {
|
||||
status: 'generating',
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: now - 180_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(dropDeadInlineGenerationPlaceholders(project, now).droppedCount).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a live-session placeholder once the window has elapsed', () => {
|
||||
// 中文注释:超过窗口意味着客户端早已 abort 并把占位改成 failed 或移除,服务端也越过了
|
||||
// 90 秒硬上限——此时还停在 generating 就确定是孤儿。
|
||||
const now = 1_700_000_000_000;
|
||||
const project = buildProject([
|
||||
buildDialogItem('stale', {
|
||||
status: 'generating',
|
||||
requiresLiveSession: true,
|
||||
generationStartedAt: now - 180_001,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
||||
|
||||
expect(result.droppedCount).toBe(1);
|
||||
expect(result.project.layers).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a live-session placeholder that carries no usable timestamp', () => {
|
||||
// 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏
|
||||
// 只是退回引入时间窗之前的行为。
|
||||
const now = 1_700_000_000_000;
|
||||
for (const generationStartedAt of [
|
||||
undefined,
|
||||
Number.NaN,
|
||||
'not-a-number',
|
||||
]) {
|
||||
const project = buildProject([
|
||||
buildDialogItem('no-timestamp', {
|
||||
status: 'generating',
|
||||
requiresLiveSession: true,
|
||||
...(generationStartedAt === undefined ? {} : { generationStartedAt }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
dropDeadInlineGenerationPlaceholders(project, now).droppedCount,
|
||||
).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps generating placeholders backed by a durable job', () => {
|
||||
// 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会
|
||||
// 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。
|
||||
|
||||
@@ -501,18 +501,37 @@ function isCanvasSettingsLayoutItem(
|
||||
return item.itemType === 'canvas-settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。
|
||||
*
|
||||
* 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由
|
||||
* `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。
|
||||
* 所以 120 秒之后客户端必已 abort,并把占位改成 `failed` 或直接移除,服务端也早已越过 90 秒。
|
||||
* 取 180 秒 = 120 秒客户端上限 + 60 秒余量(网络往返、标签页被挂起后的时钟漂移)。
|
||||
*/
|
||||
const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 180_000;
|
||||
|
||||
/**
|
||||
* 中文注释:剥离「只能由已死会话收口」的 generating 占位。
|
||||
*
|
||||
* 判据是一条结构性不变量,不是时间阈值:置位了 requiresLiveSession 的占位,其收口只能由
|
||||
* 创建它的页面会话完成;而活着的那一份始终在内存里,永远不经过快照 hydrate。因此凡是从
|
||||
* 服务端快照里读回来的这类 generating 占位,创建它的会话必然已经不在了。
|
||||
* 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端
|
||||
* 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到
|
||||
* A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前
|
||||
* revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。)
|
||||
*
|
||||
* 改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` 才判定为孤儿。
|
||||
* 不如结构性判据优雅,但那条判据在多标签页下本来就不成立。时间戳用 `generationStartedAt`,
|
||||
* 它由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,无需新增字段。
|
||||
*
|
||||
* 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移
|
||||
* 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。
|
||||
*
|
||||
* 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用,
|
||||
* 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。
|
||||
*/
|
||||
export function dropDeadInlineGenerationPlaceholders(
|
||||
project: EditorProjectSnapshot,
|
||||
now: number = Date.now(),
|
||||
): { project: EditorProjectSnapshot; droppedCount: number } {
|
||||
const nextLayers = project.layers.filter((item) => {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
@@ -523,9 +542,20 @@ export function dropDeadInlineGenerationPlaceholders(
|
||||
typeof (item as { dialog?: unknown }).dialog === 'object'
|
||||
? ((item as { dialog?: unknown }).dialog as Record<string, unknown>)
|
||||
: null;
|
||||
return !(
|
||||
dialog?.requiresLiveSession === true && dialog?.status === 'generating'
|
||||
);
|
||||
if (
|
||||
dialog?.requiresLiveSession !== true ||
|
||||
dialog?.status !== 'generating'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const startedAt = dialog.generationStartedAt;
|
||||
// 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与
|
||||
// `generationStartedAt` 在同一次创建里一起写——但兜底方向必须选对:按「保留」会让
|
||||
// 这类占位永久留在画布上,而按「剥离」最坏只是退回本次改动之前的行为。
|
||||
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
|
||||
return false;
|
||||
}
|
||||
return now - startedAt <= INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS;
|
||||
});
|
||||
const droppedCount = project.layers.length - nextLayers.length;
|
||||
if (droppedCount === 0) {
|
||||
|
||||
Reference in New Issue
Block a user