feat(platform): L1 宿主 rafLoop 异常隔离——帧回调熔断+取证旗,静默冻死改可观测降级

背景:mini-desktop 浏览器验证坐实 stub 通道 RAF 主循环无 try/catch,游戏 render 抛任意异常即 RAF 链断、
画面永久冻结、零可观测(《山海行纪》M0 arc 崩溃的放大器;游戏侧根因已另修)。
改动:stepOneFrame 游戏回调纳入 try/catch,ctx.log('err') 留痕+错误计数(host 句柄暴露 frameErrorCount);
单帧出错不断 RAF 链;连续 MAX_CONSECUTIVE_FRAME_ERRORS=10 帧(常量注释可调)才停循环并挂
window.__gameRenderError={message,stack,frame} 供取证面与九门读取;正常路径逐字不变(时钟自增在隔离外保帧时序)。
边界如实:real 通道由 littlejsengine 掌帧、其 RAF 在引擎内部,本文件不接管(代码注释标明)。
证据:新测 6/6(node 模拟 RAF+可切换抛错假游戏,覆盖四条验收行为);整库 266/266、manifest 19/19;
主会话真跑复验。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-06 19:20:45 -07:00
parent 80e8515799
commit 7f0a825382
2 changed files with 301 additions and 5 deletions

View File

@ -309,20 +309,74 @@ export async function bootGameHost(opts) {
});
}
/*
* 帧回调异常隔离可观测降级取代静默死
* 成因本宿主 stub 通道的 RAF 主循环 rafLoop stepOneFrame 原先无 try/catch update/render
* 抛任意异常即冒泡出 rafLoop其后的 requestAnimationFrame(rafLoop) 永不到达 RAF 链断画面永久
* 冻结且零可观测山海行纪arc 越界一处崩溃被此放大为整屏冻死游戏侧根因已另修
* 约束一隔离把帧内游戏回调纳入 try/catch +
* 约束二判不可恢复才停仅连续 MAX_CONSECUTIVE_FRAME_ERRORS 帧持续出错才判定为不可恢复停循环
* 并挂 window.__gameRenderError 供取证面 / 九门读取根因中间任一帧成功即把连续计数清零继续跑
* 约束三正常路径零改变成功帧仅多一句连续计数清零时钟自增与帧时序逐字不变无额外开销
* 边界只覆盖 stub 通道 host 自持的 RAFrafLoop/stepOneFramereal 通道由引擎掌帧 RAF
* littlejsengine 内部不在本文件职责面本文件不 import 引擎无从接管其循环
* */
/** 连续错误帧阈值:持续崩溃达此值才判不可恢复、停循环(可调;调高更宽容、调低更早熔断)。 */
const MAX_CONSECUTIVE_FRAME_ERRORS = 10;
let frameErrorTotal = 0; // 累计错误帧数(可观测计数,供取证面 / 九门 / host.frameErrorCount 读)。
let frameErrorStreak = 0; // 连续错误帧数(任一成功帧清零;达阈值触发停循环)。
let frameHalted = false; // 熔断标志:置真后 stepOneFrame 不再驱动游戏、rafLoop 不再续帧(可观测停死)。
/**
* 帧回调抛错的统一处置计数 + 受控日志留痕 window.__gameLog + console 镜像取代静默死
* 连续达阈值则熔断 window.__gameRenderError message/stack/frame供离线取证并让循环可观测停止
* @param {any} e 帧回调game.update/render/_refreshChecklist抛出的异常
*/
function handleFrameError(e) {
frameErrorTotal += 1;
frameErrorStreak += 1;
const frame = bundle ? bundle.frameCount() : 0;
const message = String((e && e.message) || e);
// ① 可观测留痕写受控运行时日志tag='err'),失败静默(日志绝不影响隔离本身)。
try {
if (bundle && bundle.context && typeof bundle.context.log === 'function') {
bundle.context.log('err', 'frame callback threw', { message, frame, streak: frameErrorStreak });
}
} catch (_) { /* 日志失败不影响隔离 */ }
// ③ 连续达阈值:判不可恢复,熔断 + 挂取证全局(供取证面 / 九门读根因)。
if (frameErrorStreak >= MAX_CONSECUTIVE_FRAME_ERRORS && !frameHalted) {
frameHalted = true;
try {
if (typeof window !== 'undefined') {
window.__gameRenderError = { message, stack: String((e && e.stack) || ''), frame };
}
} catch (_) { /* 取证挂载失败不阻断停循环 */ }
setStatus('RENDER HALTED: ' + message + '(连续 ' + frameErrorStreak + ' 帧出错,已停循环,见 window.__gameRenderError');
}
}
/* ── 单帧推进stub RAF / evidence stepFrames / 首帧共用game.update + game.render绘制面=renderCtx。 ── */
/** @type {CanvasRenderingContext2D|null} */
let stubRenderCtx = null;
function stepOneFrame(dt) {
const d = typeof dt === 'number' ? dt : FIXED_DT;
if (!game) return;
if (frameHalted) return; // 熔断后不再驱动游戏:画面停在最后一帧,但循环已可观测停止(非静默冻结)。
// 唯一时钟在 game.update 之前自增stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值P1 mustFix #2 口径)。
// 时钟是时间基准、置于隔离外,出错帧也照常推进 → 保时序单调、帧时序不因异常改变。
mockNowMs += d * 1000;
game.update(d);
// stub 通道绘制game.render(g) 自管铺底(与 P1 逐行等价real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制。
if (engineMode === 'stub' && stubRenderCtx) {
game.render(stubRenderCtx);
// 帧回调异常隔离game.update/render/_refreshChecklist 全纳入 try/catch单帧出错不冒泡断 RAF 链。
try {
game.update(d);
// stub 通道绘制game.render(g) 自管铺底(与 P1 逐行等价real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制。
if (engineMode === 'stub' && stubRenderCtx) {
game.render(stubRenderCtx);
}
refreshForensics();
frameErrorStreak = 0; // 成功帧:连续错误清零(正常路径唯一新增动作,无副作用、不改时序)。
} catch (e) {
handleFrameError(e); // 捕获 + 留痕 + 计数;连续达阈值则熔断并挂取证(见 handleFrameError
}
refreshForensics();
}
/** 确定性推进 n 帧evidence 驱动 / 启动首帧)。 */
function stepFrames(n) {
@ -345,6 +399,8 @@ export async function bootGameHost(opts) {
engineMode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => (bundle ? bundle.frameCount() : 0),
/** 可观测:累计帧回调错误数(帧隔离计数;正常路径恒 0供取证面 / 九门读)。 */
frameErrorCount: () => frameErrorTotal,
stepFrames,
startRaf: () => startRaf(),
stopRaf: () => stopRaf(),
@ -405,6 +461,8 @@ export async function bootGameHost(opts) {
lastTs = ts;
if (dt > 0.05) dt = 0.05; // 钳大跳帧
stepOneFrame(dt);
// 熔断(连续错误达阈值):停 RAF、不再续帧 —— 循环可观测停死(取代异常冒泡致的静默链断)。
if (frameHalted) { stopRaf(); return; }
rafId = window.requestAnimationFrame(rafLoop);
}
function startRaf() {

View File

@ -0,0 +1,238 @@
/**
* boot-game-host.test.mjs 通用宿主帧回调异常隔离 node 单测node --test 直跑
* ownerL1 宿主 运行node --test src/host/boot-game-host.test.mjs
*
* 背景boot-game-host stub 通道 RAF 主循环rafLoop stepOneFrame原先无 try/catch
* update/render 抛任意异常即冒泡出 rafLooprequestAnimationFrame 链断画面永久冻结且零可观测
* 山海行纪arc 越界崩溃被此放大为整屏冻死本测试在 node 环境模拟 window/canvas/RAF用可切换
* 抛错的假游戏驱动 stub 通道逐条验证异常隔离 + 可观测降级取代静默死四条验收行为
* render/update 回调抛错被捕获ctx.log('err', ) 留痕 + 累计错误计数
* 单帧出错不断 RAF 下一帧继续成功帧把连续计数清零
* 连续 N 帧错误N=MAX_CONSECUTIVE_FRAME_ERRORS=10才停循环停时挂
* window.__gameRenderError = { message, stack, frame } 供取证面 / 九门读取
* 正常路径行为逐字不变游戏回调逐帧各一次dt 时序不变零错误无取证副作用
*
* 测试基建手搓 windowaddEventListener/requestAnimationFrame 队列可控泵+ mock canvas + 可切换抛错
* 的假游戏记录 update/render 调用与 dt零第三方依赖 node:test/node:assert
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bootGameHost } from './boot-game-host.js';
/* ── 测试基建 ──────────────────────────────────────────────────────────────── */
/** N=10 阈值(与 boot-game-host.js 内 MAX_CONSECUTIVE_FRAME_ERRORS 常量对齐;改那里须改这里)。 */
const N = 10;
/**
* 安装 node 环境的浏览器桩globalThis.window addEventListener + 可控 RAF 队列
* RAF 用手动泵实现requestAnimationFrame 入队pumpFrame(ts) 出队执行一次rafLoop 内部续帧落入下一批
* @returns {{ window: any, canvas: any, pumpFrame: (ts:number)=>void, rafPending: ()=>number, restore: ()=>void }}
*/
function installEnv() {
let rafQueue = [];
let rafSeq = 1;
const prevWindow = globalThis.window;
const window = {
addEventListener() { /* error/unhandledrejection/keydown/keyup 桩:本测试不派发这些事件 */ },
requestAnimationFrame(cb) { const id = rafSeq++; rafQueue.push({ id, cb }); return id; },
cancelAnimationFrame(id) { rafQueue = rafQueue.filter((r) => r.id !== id); },
};
globalThis.window = window;
// 单个 2D 上下文桩setTransform/save/restore + 兜底代理(游戏 render 由测试自控,不依赖真绘制)。
const ctx2d = new Proxy({}, { get: () => () => {} });
const canvas = {
width: 0, height: 0, style: {},
getContext() { return ctx2d; },
addEventListener() { /* attachInput 挂 pointer 事件:本测试不派发 */ },
getBoundingClientRect() { return { left: 0, top: 0, width: 390, height: 844 }; },
};
/** 执行当前已排帧一次rafLoop 续帧进入下一批,故每次 pump = 恰一帧)。 */
function pumpFrame(ts) {
const due = rafQueue;
rafQueue = [];
for (const r of due) r.cb(ts);
}
return {
window,
canvas,
pumpFrame,
rafPending: () => rafQueue.length,
restore() { if (prevWindow === undefined) delete globalThis.window; else globalThis.window = prevWindow; },
};
}
/**
* 造一个可切换抛错的假游戏并记录 update/render 调用
* @param {{throwOnUpdate?:boolean, throwOnRender?:boolean}} control 运行时可改的抛错开关
* @param {{update:number, render:number, dts:number[]}} calls 调用记录测试断言用
*/
function makeGame(control, calls) {
return {
async init() { /* boot 成功init 不抛错 */ },
update(dt) {
calls.update += 1;
calls.dts.push(dt);
if (control.throwOnUpdate) throw new Error('UPDATE_BOOM');
},
render() {
calls.render += 1;
if (control.throwOnRender) throw new Error('RENDER_BOOM');
},
destroy() {},
};
}
/**
* 启动 stub/play 宿主返回 host + 环境 + 抛错开关 + 调用记录
* boot 期间 setupCommon 会走一次首帧 stepOneFrame非抛错play 模式随后自动 startRaf 排入首个 rafLoop
*/
async function bootStub() {
const env = installEnv();
const control = { throwOnUpdate: false, throwOnRender: false };
const calls = { update: 0, render: 0, dts: [] };
const host = await bootGameHost({
canvas: env.canvas,
seed: 12345,
mode: 'play',
engineMode: 'stub',
viewport: { w: 390, h: 844 },
factory: () => makeGame(control, calls),
});
// 重置记录:只统计 boot 之后(泵入)的帧,排除 boot 首帧噪声。
calls.update = 0; calls.render = 0; calls.dts.length = 0;
return { host, env, control, calls };
}
/* ── ④ 正常路径行为逐字不变(先测基线,确认桩正确 + 零副作用)──────────────────── */
test('④ 正常路径:非抛错游戏逐帧各调一次 update/renderdt 时序不变零错误、无取证副作用、RAF 链续', async () => {
const { host, env, calls } = await bootStub();
const tsSeq = [16, 32, 48, 64, 80];
for (const ts of tsSeq) env.pumpFrame(ts);
// 游戏回调逐帧各一次(无漏帧 / 无重复驱动)。
assert.equal(calls.update, tsSeq.length, 'update 应每帧恰一次');
assert.equal(calls.render, tsSeq.length, 'render 应每帧恰一次');
// dt 时序:首帧 dt=0lastTs 初始化),其后各帧 =(Δts)/1000=0.016,隔离层未改帧时序。
assert.equal(calls.dts[0], 0, '首帧 dt 应为 0');
const expectDt = (32 - 16) / 1000;
for (let i = 1; i < calls.dts.length; i++) {
assert.ok(Math.abs(calls.dts[i] - expectDt) < 1e-9, `${i} 帧 dt 应为 ${expectDt}`);
}
// 零错误、无熔断取证、RAF 链仍在续帧(下一帧已排队)。
assert.equal(host.frameErrorCount(), 0, '正常路径累计错误计数应为 0');
assert.equal(env.window.__gameRenderError, undefined, '正常路径不应挂 __gameRenderError');
assert.equal(env.rafPending(), 1, '正常路径 RAF 链应持续续帧(下一帧已排队)');
env.restore();
});
/* ── ① 回调抛错被捕获 + ctx.log('err') 留痕 + 错误计数 ─────────────────────────── */
test('① render 抛错被捕获不冒泡、ctx.log(err) 写入 __gameLog、累计错误计数 +1', async () => {
const { host, env, control } = await bootStub();
control.throwOnRender = true; // 让下一帧 render 抛错
// pumpFrame 不应抛出(异常被 stepOneFrame 隔离,不冒泡出 rafLoop
assert.doesNotThrow(() => env.pumpFrame(16), '帧回调抛错不应冒泡到 RAF 泵');
// 累计错误计数 +1。
assert.equal(host.frameErrorCount(), 1, '一帧 render 抛错 → 累计错误计数应为 1');
// ctx.log('err', …) 留痕window.__gameLog 出现 tag='err' 的条目。
const log = env.window.__gameLog || [];
const errEntries = log.filter((e) => e && e.tag === 'err');
assert.ok(errEntries.length >= 1, '__gameLog 应含至少一条 tag=err 的留痕');
assert.ok(/RENDER_BOOM/.test(errEntries[errEntries.length - 1].msg), '留痕 msg 应含真实错误信息');
env.restore();
});
test('① update 抛错同样被捕获并计数(隔离覆盖 update/render 两个回调)', async () => {
const { host, env, control } = await bootStub();
control.throwOnUpdate = true;
assert.doesNotThrow(() => env.pumpFrame(16), 'update 抛错不应冒泡');
assert.equal(host.frameErrorCount(), 1, 'update 抛错应计入错误计数');
env.restore();
});
/* ── ② 单帧出错不断 RAF 链(下一帧继续;成功帧清零连续计数)─────────────────────── */
test('② 单帧出错不断链:错误帧后 RAF 仍续帧,随后成功帧恢复且把连续计数清零', async () => {
const { host, env, control, calls } = await bootStub();
// 第 1 帧render 抛错 → 计数 1、未熔断、RAF 链仍在。
control.throwOnRender = true;
env.pumpFrame(16);
assert.equal(host.frameErrorCount(), 1);
assert.equal(env.window.__gameRenderError, undefined, '单帧错误不应熔断');
assert.equal(env.rafPending(), 1, '单帧错误后 RAF 链应继续(下一帧已排队)');
// 第 2 帧:恢复正常 → 成功帧照常驱动、连续计数清零(错误总数不再增长)。
control.throwOnRender = false;
const updateBefore = calls.update;
env.pumpFrame(32);
assert.ok(calls.update > updateBefore, '错误后下一帧应继续驱动 update链未断');
assert.equal(host.frameErrorCount(), 1, '成功帧不增错误总数(累计仍为 1');
// 第 3 帧再次抛错:连续计数已被上一成功帧清零,故这是「连续第 1 帧」而非累积——远不触发 N 熔断。
control.throwOnRender = true;
env.pumpFrame(48);
assert.equal(host.frameErrorCount(), 2);
assert.equal(env.window.__gameRenderError, undefined, '间歇性错误(非连续 N 帧)不应熔断');
assert.equal(env.rafPending(), 1, 'RAF 链应始终存活于间歇性错误');
env.restore();
});
/* ── ③ 连续 N 帧错误才停循环 + 挂 window.__gameRenderError ───────────────────────── */
test('③ 连续 N-1 帧错误仍不熔断(阈值边界下沿)', async () => {
const { host, env, control } = await bootStub();
control.throwOnRender = true;
for (let i = 0; i < N - 1; i++) env.pumpFrame(16 * (i + 1));
assert.equal(host.frameErrorCount(), N - 1, `连续 ${N - 1} 帧错误累计计数`);
assert.equal(env.window.__gameRenderError, undefined, `连续 ${N - 1} 帧(< N不应熔断`);
assert.equal(env.rafPending(), 1, '未熔断时 RAF 链应仍在续帧');
env.restore();
});
test('③ 连续 N 帧错误 → 熔断停循环:挂 __gameRenderError{message,stack,frame} 且 RAF 不再续帧', async () => {
const { host, env, control } = await bootStub();
control.throwOnRender = true;
for (let i = 0; i < N; i++) env.pumpFrame(16 * (i + 1));
// 熔断:累计计数 = N取证全局挂载。
assert.equal(host.frameErrorCount(), N, `连续 ${N} 帧错误应达阈值`);
const rec = env.window.__gameRenderError;
assert.ok(rec && typeof rec === 'object', '熔断应挂 window.__gameRenderError');
assert.ok(/RENDER_BOOM/.test(rec.message), '__gameRenderError.message 应含真实错误信息');
assert.equal(typeof rec.stack, 'string', '__gameRenderError.stack 应为字符串堆栈');
assert.equal(typeof rec.frame, 'number', '__gameRenderError.frame 应为帧号');
// 停循环RAF 不再续帧stopRaf 后队列空)。
assert.equal(env.rafPending(), 0, '熔断后 RAF 链应停止(不再排队续帧)');
// 熔断后再泵stepOneFrame 早退、不再驱动游戏、错误计数不再增长(可观测停死,非静默)。
const countAtHalt = host.frameErrorCount();
env.pumpFrame(16 * (N + 1));
assert.equal(host.frameErrorCount(), countAtHalt, '熔断后不再驱动游戏,错误计数不再增长');
env.restore();
});