From 5a43d58f8689702fa6b0dce9f5e9a30e94907a91 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 21:39:20 +0800 Subject: [PATCH] fix(kernel): harden runtime event compatibility and dispatch safety Restore event bus variadic emit/listener semantics for backward compatibility, and make RuntimeEvents resilient to listener failures including once cleanup on throws. Split frame render signaling from generic render calls so game:rendered is emitted only from the frame loop, with a dedicated game:frame-rendered event. Made-with: Cursor --- src/core/Game.ts | 12 ++++++++++-- src/init.ts | 8 +++++--- src/kernel/RuntimeEvents.ts | 30 +++++++++++++++++----------- tests/kernel/runtime-events.test.ts | 39 ++++++++++++++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/core/Game.ts b/src/core/Game.ts index ad3959c..adf5773 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -32,6 +32,7 @@ class Game { private _orientationLayoutGen = 0; private _rootResizeObserver: ResizeObserver | null = null; private _afterRender: (() => void) | null = null; + private _afterFrameRender: (() => void) | null = null; private constructor() {} @@ -278,17 +279,24 @@ class Game { this.info.height = pw / scaleRatio; } - this.render(); + this.render("layout"); } - render(): void { + render(source: "frame" | "layout" | "manual" = "manual"): void { this.renderer.render(this.stage); this._afterRender?.(); + if (source === "frame") { + this._afterFrameRender?.(); + } } setAfterRenderHook(hook: (() => void) | null): void { this._afterRender = hook; } + + setAfterFrameRenderHook(hook: (() => void) | null): void { + this._afterFrameRender = hook; + } } export default Game; diff --git a/src/init.ts b/src/init.ts index ea80a11..391ad04 100644 --- a/src/init.ts +++ b/src/init.ts @@ -14,8 +14,10 @@ const eventBus = appRuntime.events; const runtimeSceneEventsPlugin: RuntimePlugin = { name: "runtime-scene-events", setup: ({ game: runtimeGame, sceneManager: runtimeSceneManager, events }) => { - runtimeGame.setAfterRenderHook(() => { - events.emit("game:rendered", undefined); + runtimeGame.setAfterFrameRenderHook(() => { + // Keep legacy event name, but only emit from frame loop. + events.emit("game:rendered"); + events.emit("game:frame-rendered"); }); runtimeSceneManager.onStageChange((current, previous) => { events.emit("scene:changed", { @@ -89,7 +91,7 @@ export async function initApp(): Promise { current.update?.(dt, current.name, ticker); } - game.render(); + game.render("frame"); for (const scene of sceneManager.getAllScenes()) { if (scene.type === SceneType.Resident && scene.stage.visible && scene.lateUpdate) { diff --git a/src/kernel/RuntimeEvents.ts b/src/kernel/RuntimeEvents.ts index 1f83a73..1873ac4 100644 --- a/src/kernel/RuntimeEvents.ts +++ b/src/kernel/RuntimeEvents.ts @@ -1,51 +1,59 @@ -type EventCallback = (payload: T) => void; +type EventCallback = (...args: unknown[]) => void; export interface RuntimeEventMap { "runtime:ready": { startedAt: number }; "scene:changed": { current: string; previous?: string }; "game:rendered": undefined; + "game:frame-rendered": undefined; } export class RuntimeEvents { private events = new Map>(); - on(event: string, callback: EventCallback): () => void { + on(event: string, callback: EventCallback): () => void { if (!this.events.has(event)) { this.events.set(event, new Set()); } - this.events.get(event)!.add(callback as EventCallback); + this.events.get(event)!.add(callback); return () => this.off(event, callback); } - off(event: string, callback: EventCallback): void { + off(event: string, callback: EventCallback): void { const callbacks = this.events.get(event); if (!callbacks) { return; } - callbacks.delete(callback as EventCallback); + callbacks.delete(callback); if (callbacks.size === 0) { this.events.delete(event); } } - once(event: string, callback: EventCallback): () => void { - const wrapped: EventCallback = (payload) => { - callback(payload); - this.off(event, wrapped); + once(event: string, callback: EventCallback): () => void { + const wrapped: EventCallback = (...args) => { + try { + callback(...args); + } finally { + this.off(event, wrapped); + } }; return this.on(event, wrapped); } - emit(event: string, payload: T): void { + emit(event: string, ...args: unknown[]): void { const callbacks = this.events.get(event); if (!callbacks) { return; } callbacks.forEach((cb) => { - cb(payload); + try { + cb(...args); + } catch (error) { + console.error(`RuntimeEvents: listener failed for "${event}"`, error); + } }); } diff --git a/tests/kernel/runtime-events.test.ts b/tests/kernel/runtime-events.test.ts index 36f9fe7..6759ffd 100644 --- a/tests/kernel/runtime-events.test.ts +++ b/tests/kernel/runtime-events.test.ts @@ -34,17 +34,17 @@ vi.mock("@/scene/SceneManager", () => { }); describe("RuntimeEvents", () => { - it("supports on/off/emit", () => { + it("supports on/off/emit with variadic args", () => { const events = new RuntimeEvents(); const handler = vi.fn(); events.on("runtime:ready", handler); - events.emit("runtime:ready", { startedAt: 1 }); + events.emit("runtime:ready", { startedAt: 1 }, "extra", 42); events.off("runtime:ready", handler); events.emit("runtime:ready", { startedAt: 2 }); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith({ startedAt: 1 }); + expect(handler).toHaveBeenCalledWith({ startedAt: 1 }, "extra", 42); }); it("supports once subscriptions", () => { @@ -58,6 +58,39 @@ describe("RuntimeEvents", () => { expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith({ current: "a", previous: undefined }); }); + + it("keeps dispatching when one listener throws", () => { + const events = new RuntimeEvents(); + const bad = vi.fn(() => { + throw new Error("boom"); + }); + const good = vi.fn(); + + events.on("scene:changed", bad); + events.on("scene:changed", good); + events.emit("scene:changed", { current: "x" }); + + expect(bad).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledWith({ current: "x" }); + }); + + it("unsubscribes once listener even when callback throws", () => { + const events = new RuntimeEvents(); + const throwing = vi.fn(() => { + throw new Error("once failed"); + }); + const after = vi.fn(); + + events.once("runtime:ready", throwing); + events.on("runtime:ready", after); + + events.emit("runtime:ready", { startedAt: 1 }); + events.emit("runtime:ready", { startedAt: 2 }); + + expect(throwing).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledTimes(2); + }); }); describe("AppRuntime plugin lifecycle", () => {