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:
parent
80e8515799
commit
7f0a825382
@ -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 自持的 RAF(rafLoop/stepOneFrame);real 通道由引擎掌帧、其 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() {
|
||||
|
||||
238
game-runtime/src/host/boot-game-host.test.mjs
Normal file
238
game-runtime/src/host/boot-game-host.test.mjs
Normal file
@ -0,0 +1,238 @@
|
||||
/**
|
||||
* boot-game-host.test.mjs — 通用宿主帧回调异常隔离 node 单测(node --test 直跑)
|
||||
* owner:L1 宿主 | 运行:node --test src/host/boot-game-host.test.mjs
|
||||
*
|
||||
* 背景:boot-game-host 的 stub 通道 RAF 主循环(rafLoop → stepOneFrame)原先无 try/catch,游戏
|
||||
* update/render 抛任意异常即冒泡出 rafLoop、requestAnimationFrame 链断、画面永久冻结且零可观测
|
||||
* (《山海行纪》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 时序不变、零错误、无取证副作用)。
|
||||
*
|
||||
* 测试基建:手搓 window(addEventListener/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/render,dt 时序不变,零错误、无取证副作用、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=0(lastTs 初始化),其后各帧 =(Δ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();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user