Skip to content

ADR-106: 时序审核与异步生命周期规范

状态: ✅ 全部完成(Phase 1 ✅ + Phase 2 ✅ + Phase 3 ✅) 日期: 2026-07-16

2026-07-16 对账修正:P1 waitForTransition 安全网(events.ts:105 Math.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

根因分析:

  1. HMR 无清理契约 — 模块级 observer、定时器、订阅者在 HMR 重载后无统一清理入口,依赖"运气"不访问已销毁对象
  2. 异步链路无取消点 — 起始 await 前不检查"是否还有意义",导致焦点切换后 CPU 浪费
  3. 过渡动画安全网不足 — 依赖 CSS transitionend 的 Promise 没有足够余量
  4. fire-and-forget 的回调链onRemoveModel 中异步清理与同步销毁的时序未显式声明
  5. 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、定时器、订阅者的模块必须导出 disposestop 函数,在 initScene 重入时调用。

需要清理的模块

模块清理函数注册内容
core/reactivity.tsunsubscribeAll()_subscribers Set
scene/motion/bone-override.tsstopBoneOverride()onBeforeRenderObservable
scene/motion/feet-adjustment.tsstopFeetAdjustment()onBeforeRenderObservable
scene/env/env-bridge.ts_envPersistTimer.cancel()DebouncedTimer
scene/render/renderer.tsdisposeRenderer()✅ 已有
core/render-loop.tsstopRenderLoop()✅ 已有
core/events.tsdisposeEventHandlers()✅ 已有

调用规范

typescript
// initScene 重入时,在开头调用所有清理函数
export async function initScene(): Promise<void> {
    // 0. 清理旧实例
    _disposePlaybackObservables?.();
    stopBoneOverride();
    stopFeetAdjustment();
    // ... 原有初始化逻辑
}

D4: 异步函数在关键 await 前必须检查"是否还有意义"

所有通过 updateProcMotionstartProcMotion 类似链路触发的异步操作,在 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
}

背景initControlui-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.ts89waitForTransition 安全网 dur + 50 余量不足D1
core/load-manager.ts61enqueue.then(task, task) 隐式依赖D2

P2 — 建议修复

文件行号问题规则
scene/motion/proc-motion-bridge.ts103startProcMotionawait loadVMDMotion 前无焦点检查D4
core/reactivity.ts36subscribe 无 HMR 清理导出D3
scene/motion/bone-override.ts260stopBoneOverride() 已导出(L260),HMR 清理入口具备,P2 已满足(2026-07-14 核验)D3
scene/motion/feet-adjustment.ts313stopFeetAdjustment() 已导出(L313),HMR 清理入口具备,P2 已满足(2026-07-14 核验)D3
scene/scene-serialize.ts455deserializeScenefocusedModel() 反查模型D5

P3 — 规划修复

文件行号问题规则
scene/env/env-bridge.ts650_envPersistTimer HMR 时悬挂(已修复:新增 cancelEnvPersistTimer 导出,2026-07-14)D3
scene/manager/model-loader.ts102withTimeout 超时后原始 Promise 不取消(已评估 N/A:Promise 不可取消 + captureThumbnail 有 generation counter 防护,2026-07-14)D4(可选)
menus/menu.ts797SlideMenu.dispose() 未清空 _controls 数组,initControl 注册的闭包残留(已修复:L814-816 补全清理,2026-07-16)D6

实施计划

Phase 1: P1 修复(1 天)

  1. core/events.tswaitForTransition 安全网提升至 Math.max(dur * 2, 500)
  2. core/load-manager.tsenqueue.then 分离 onFulfilled/onRejected

Phase 2: P2 修复(2 天)

  1. scene/motion/proc-motion-bridge.tsstartProcMotionawait 前增加焦点检查
  2. core/reactivity.ts — 导出 unsubscribeAll()
  3. scene/motion/bone-override.ts — 导出 stopBoneOverride()
  4. scene/motion/feet-adjustment.ts — 导出 stopFeetAdjustment()
  5. scene/scene-serialize.tsdeserializeScene 改用 loadPMXFile 返回的 ID

Phase 3: 新增 HMR 清理入口(已完成 2026-07-14)

  1. ✅ 在 initScene 开头调用所有已注册的 stop* 函数(_disposePlaybackObservables?.() + stopBoneOverride() + stopFeetAdjustment() + unsubscribeAll() + cancelEnvPersistTimer()
  2. ✅ 在 initScene 开头调用 unsubscribeAll()(同上清理块,D3 要求)
  3. SlideMenu.dispose() 补全 _controls = [] + panel.innerHTML = ''(D6,2026-07-16)

验收标准

  1. P1 修复waitForTransition 安全网 ≥ 500ms,enqueue.then 显式分离
  2. P2 修复:所有 5 个修复点通过代码审查
  3. HMR 清理initScene 重入时不会残留 observer 或定时器
  4. SlideMenu 清理dispose()_controls 为空、panel DOM 已清除(D6)
  5. 构建通过npm run build 无错误
  6. E2E 测试通过npm run test:e2e 无回归

相关 ADR


弃用说明

无。


本 ADR 由 2026-07-14 全项目时序审核生成