Appearance
ADR-106: 时序审核与异步生命周期规范
状态: ✅ 全部完成(Phase 1 ✅ + Phase 2 ✅ + Phase 3 ✅) 日期: 2026-07-16
2026-07-16 对账修正:P1
waitForTransition安全网(events.ts:105Math.max(dur*2,500))与enqueue显式 onRejected(load-manager.ts)经代码核实已修复,更新从"待修复"→✅ 已完成。2026-07-16 再次对账:P2 全部 5 项经代码核实已实现(3 项在 Phase 3 整改中顺手修完,2 项在 P2 审计时即已满足),更新状态从"P2 🟡 待修复"→✅ 已完成。
2026-07-21 回归修正:
bfcacc35b(HMR 级联释放)顺手在cleanupAndFlushSave()中加入disposeScene(),导致visibilitychange → hidden(最小化/Alt+Tab)误销毁 Scene/Engine,切回后渲染器冻结。已由36b529b0修复:disposeScene()仅保留在beforeunload(真正退出)路径,hidden只刷盘。详见docs/buglog/2026-07-21-visibilitychange-dispose-scene-freeze.md。
决策者: Riku(联邦首席架构师 AI)、Jieling(人类侧首席架构师)
创建日期: 2026-07-14
背景
2026-07-14 全项目时序专项审核覆盖 frontend/src/ 全部 12 个目录、40+ 时序敏感文件,发现以下系统性缺陷:
| 问题域 | 数量 | 严重程度 |
|---|---|---|
| 动画过渡死锁风险 | 1 | 🔴 P1 |
| Promise 链隐式契约 | 1 | 🔴 P1 |
| HMR 生命周期泄漏 | 3 | 🟠 P2 |
| 异步浪费(无提前取消检查) | 1 | 🟠 P2 |
| 反查依赖隐含假设 | 1 | 🟠 P2 |
| 清理入口缺失 | 2 | 🟡 P3 |
| 资源浪费 | 1 | 🟢 P4 |
根因分析:
- HMR 无清理契约 — 模块级 observer、定时器、订阅者在 HMR 重载后无统一清理入口,依赖"运气"不访问已销毁对象
- 异步链路无取消点 — 起始
await前不检查"是否还有意义",导致焦点切换后 CPU 浪费 - 过渡动画安全网不足 — 依赖 CSS transitionend 的 Promise 没有足够余量
- fire-and-forget 的回调链 —
onRemoveModel中异步清理与同步销毁的时序未显式声明 - UI 控件生命周期断裂 —
initControl通过registerControl注册闭包到SlideMenu._controls,但dispose()路径未清空该数组,闭包引用延迟释放
决策
D1: 过渡动画超时安全网规范
所有 waitForTransition 类 Promise 的超时安全网必须 ≥ 2× transition-duration,且 ≥ 500ms。
typescript
// ✅ 正确
function waitForTransition(el: HTMLElement, propertyName?: string): Promise<void> {
return new Promise((resolve) => {
const dur = parseFloat(getComputedStyle(el).transitionDuration) * 1000 || 0;
if (dur <= 0) { resolve(); return; }
const disp = addDisposableListener(el, 'transitionend', (e) => {
if (propertyName && (e as TransitionEvent).propertyName !== propertyName) return;
disp.dispose();
resolve();
});
const timeout = Math.max(dur * 2, 500); // ← 2x + 下限
setTimeout(resolve, timeout);
});
}违规检查
setTimeout(resolve, dur + <常量>)且常量 < 500 → 违规setTimeout(resolve, dur)无额外余量 → 违规
D2: Promise 链的 onRejected 必须显式
所有 .then(onFulfilled, onRejected) 模式中,onRejected 必须显式处理拒绝原因,不得依赖函数忽略参数。
typescript
// ✅ 正确
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this.queue.then(
task,
(err) => {
console.warn('[loadManager] 上一个任务失败,继续:', err);
return task(); // 显式调用,不传 err
}
);
this.queue = result.then(() => {}, () => {});
return result;
}
// ❌ 错误:隐式依赖 task 忽略参数
this.queue.then(task, task)D3: 每个模块必须有 HMR 清理函数
所有注册 observer、定时器、订阅者的模块必须导出 dispose 或 stop 函数,在 initScene 重入时调用。
需要清理的模块
| 模块 | 清理函数 | 注册内容 |
|---|---|---|
core/reactivity.ts | unsubscribeAll() | _subscribers Set |
scene/motion/bone-override.ts | stopBoneOverride() | onBeforeRenderObservable |
scene/motion/feet-adjustment.ts | stopFeetAdjustment() | onBeforeRenderObservable |
scene/env/env-bridge.ts | _envPersistTimer.cancel() | DebouncedTimer |
scene/render/renderer.ts | disposeRenderer() | ✅ 已有 |
core/render-loop.ts | stopRenderLoop() | ✅ 已有 |
core/events.ts | disposeEventHandlers() | ✅ 已有 |
调用规范
typescript
// initScene 重入时,在开头调用所有清理函数
export async function initScene(): Promise<void> {
// 0. 清理旧实例
_disposePlaybackObservables?.();
stopBoneOverride();
stopFeetAdjustment();
// ... 原有初始化逻辑
}D4: 异步函数在关键 await 前必须检查"是否还有意义"
所有通过 updateProcMotion → startProcMotion 类似链路触发的异步操作,在 await 之前必须检查焦点/状态是否已变化。
typescript
// ✅ 正确
async function startProcMotion(...) {
if (procStarting) return;
procStarting = true;
const modelIdAtStart = focusedModelId;
// 生成 VMD buffer ...
// 在 await 前快速检查
if (focusedModelId !== modelIdAtStart) {
procStarting = false;
return; // ← 提前返回,不浪费 await
}
await loadVMDMotion(buf, ...);
// ...
}D5: deserializeScene 禁止使用 focusedModel() 反查
所有需要获取刚加载的模型实例的代码,必须使用加载函数返回的 ID 直接查询,不得依赖"焦点模型就是刚加载的模型"这一隐含假设。
typescript
// ✅ 正确
const loadedId = await loadPMXFile(resolvedPath, m.kind === 'stage', true);
if (!loadedId) { modelIds.push(null); continue; }
const inst = modelRegistry.get(loadedId);
modelIds.push(inst ? loadedId : null);
// ❌ 错误
await loadPMXFile(resolvedPath, m.kind === 'stage', true);
const inst = focusedModel(); // 隐含假设D6: SlideMenu.dispose() 必须清理 initControl 注册的闭包
SlideMenu.dispose() 必须显式清空 _controls 数组并清除 panel DOM,避免 initControl 注册的闭包引用(捕获 HTMLElement + bind 函数)在菜单销毁后残留。
typescript
// ✅ 正确 — menu.ts:SlideMenu.dispose()
dispose(): void {
// ... 现有清理 ...
this._controls = []; // 释放 initControl 闭包引用
this.panel.innerHTML = ''; // 显式清除 DOM,配合 GC
}背景:initControl(ui-rows.ts:105)通过 getCurrentRenderingMenu()?.registerControl(update) 将更新回调注册到 SlideMenu._controls。菜单销毁时若不清空,闭包捕获的 DOM 元素和 bind 函数会延迟释放。buildPanel() 虽会在重建时清空 _controls,但 dispose() 路径缺失此清理。
调用链路
addToggleRow / addSliderRow / addColorSliderRow
→ initControl(el, opts, initial, apply)
→ getCurrentRenderingMenu()?.registerControl(update) // 注册到 _controls
→ update() // 立即初始化
SlideMenu.dispose()
→ _cancelAnim()
→ _unsubscribe()
→ _keydownDisp?.dispose()
→ _controls = [] // D6: 清理
→ panel.innerHTML = '' // D6: 清理受影响文件
P1 — 必须修复
| 文件 | 行号 | 问题 | 规则 |
|---|---|---|---|
core/events.ts | 89 | waitForTransition 安全网 dur + 50 余量不足 | D1 |
core/load-manager.ts | 61 | enqueue 的 .then(task, task) 隐式依赖 | D2 |
P2 — 建议修复
| 文件 | 行号 | 问题 | 规则 |
|---|---|---|---|
scene/motion/proc-motion-bridge.ts | 103 | startProcMotion 在 await loadVMDMotion 前无焦点检查 | D4 |
core/reactivity.ts | 36 | subscribe 无 HMR 清理导出 | D3 |
scene/motion/bone-override.ts | 260 | stopBoneOverride() 已导出(L260),HMR 清理入口具备,P2 已满足(2026-07-14 核验) | D3 |
scene/motion/feet-adjustment.ts | 313 | stopFeetAdjustment() 已导出(L313),HMR 清理入口具备,P2 已满足(2026-07-14 核验) | D3 |
scene/scene-serialize.ts | 455 | deserializeScene 用 focusedModel() 反查模型 | D5 |
P3 — 规划修复
| 文件 | 行号 | 问题 | 规则 |
|---|---|---|---|
scene/env/env-bridge.ts | 650 | _envPersistTimer HMR 时悬挂(已修复:新增 cancelEnvPersistTimer 导出,2026-07-14) | D3 |
scene/manager/model-loader.ts | 102 | withTimeout 超时后原始 Promise 不取消(已评估 N/A:Promise 不可取消 + captureThumbnail 有 generation counter 防护,2026-07-14) | D4(可选) |
menus/menu.ts | 797 | SlideMenu.dispose() 未清空 _controls 数组,initControl 注册的闭包残留(已修复:L814-816 补全清理,2026-07-16) | D6 |
实施计划
Phase 1: P1 修复(1 天)
core/events.ts—waitForTransition安全网提升至Math.max(dur * 2, 500)core/load-manager.ts—enqueue的.then分离 onFulfilled/onRejected
Phase 2: P2 修复(2 天)
scene/motion/proc-motion-bridge.ts—startProcMotion在await前增加焦点检查core/reactivity.ts— 导出unsubscribeAll()scene/motion/bone-override.ts— 导出stopBoneOverride()scene/motion/feet-adjustment.ts— 导出stopFeetAdjustment()scene/scene-serialize.ts—deserializeScene改用loadPMXFile返回的 ID
Phase 3: 新增 HMR 清理入口(已完成 2026-07-14)
- ✅ 在
initScene开头调用所有已注册的stop*函数(_disposePlaybackObservables?.()+stopBoneOverride()+stopFeetAdjustment()+unsubscribeAll()+cancelEnvPersistTimer()) - ✅ 在
initScene开头调用unsubscribeAll()(同上清理块,D3 要求) - ✅
SlideMenu.dispose()补全_controls = []+panel.innerHTML = ''(D6,2026-07-16)
验收标准
- P1 修复:
waitForTransition安全网 ≥ 500ms,enqueue的.then显式分离 - P2 修复:所有 5 个修复点通过代码审查
- HMR 清理:
initScene重入时不会残留 observer 或定时器 - SlideMenu 清理:
dispose()后_controls为空、panel DOM 已清除(D6) - 构建通过:
npm run build无错误 - E2E 测试通过:
npm run test:e2e无回归
相关 ADR
- ADR-105: AbortSignal 传递规范与异步异常处理基线 — 异步取消与错误处理配套规范
- ADR-102: main.ts 拆分 —
render-loop.ts提取与幂等 stop - ADR-063: 架构债务偿还 — 历史架构债务清单
弃用说明
无。
本 ADR 由 2026-07-14 全项目时序审核生成