Browse Source

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
master
npmrun 2 weeks ago
parent
commit
5a43d58f86
  1. 12
      src/core/Game.ts
  2. 8
      src/init.ts
  3. 30
      src/kernel/RuntimeEvents.ts
  4. 39
      tests/kernel/runtime-events.test.ts

12
src/core/Game.ts

@ -32,6 +32,7 @@ class Game {
private _orientationLayoutGen = 0; private _orientationLayoutGen = 0;
private _rootResizeObserver: ResizeObserver | null = null; private _rootResizeObserver: ResizeObserver | null = null;
private _afterRender: (() => void) | null = null; private _afterRender: (() => void) | null = null;
private _afterFrameRender: (() => void) | null = null;
private constructor() {} private constructor() {}
@ -278,17 +279,24 @@ class Game {
this.info.height = pw / scaleRatio; 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.renderer.render(this.stage);
this._afterRender?.(); this._afterRender?.();
if (source === "frame") {
this._afterFrameRender?.();
}
} }
setAfterRenderHook(hook: (() => void) | null): void { setAfterRenderHook(hook: (() => void) | null): void {
this._afterRender = hook; this._afterRender = hook;
} }
setAfterFrameRenderHook(hook: (() => void) | null): void {
this._afterFrameRender = hook;
}
} }
export default Game; export default Game;

8
src/init.ts

@ -14,8 +14,10 @@ const eventBus = appRuntime.events;
const runtimeSceneEventsPlugin: RuntimePlugin = { const runtimeSceneEventsPlugin: RuntimePlugin = {
name: "runtime-scene-events", name: "runtime-scene-events",
setup: ({ game: runtimeGame, sceneManager: runtimeSceneManager, events }) => { setup: ({ game: runtimeGame, sceneManager: runtimeSceneManager, events }) => {
runtimeGame.setAfterRenderHook(() => { runtimeGame.setAfterFrameRenderHook(() => {
events.emit("game:rendered", undefined); // Keep legacy event name, but only emit from frame loop.
events.emit("game:rendered");
events.emit("game:frame-rendered");
}); });
runtimeSceneManager.onStageChange((current, previous) => { runtimeSceneManager.onStageChange((current, previous) => {
events.emit("scene:changed", { events.emit("scene:changed", {
@ -89,7 +91,7 @@ export async function initApp(): Promise<void> {
current.update?.(dt, current.name, ticker); current.update?.(dt, current.name, ticker);
} }
game.render(); game.render("frame");
for (const scene of sceneManager.getAllScenes()) { for (const scene of sceneManager.getAllScenes()) {
if (scene.type === SceneType.Resident && scene.stage.visible && scene.lateUpdate) { if (scene.type === SceneType.Resident && scene.stage.visible && scene.lateUpdate) {

30
src/kernel/RuntimeEvents.ts

@ -1,51 +1,59 @@
type EventCallback<T = unknown> = (payload: T) => void; type EventCallback = (...args: unknown[]) => void;
export interface RuntimeEventMap { export interface RuntimeEventMap {
"runtime:ready": { startedAt: number }; "runtime:ready": { startedAt: number };
"scene:changed": { current: string; previous?: string }; "scene:changed": { current: string; previous?: string };
"game:rendered": undefined; "game:rendered": undefined;
"game:frame-rendered": undefined;
} }
export class RuntimeEvents { export class RuntimeEvents {
private events = new Map<string, Set<EventCallback>>(); private events = new Map<string, Set<EventCallback>>();
on<T = unknown>(event: string, callback: EventCallback<T>): () => void { on(event: string, callback: EventCallback): () => void {
if (!this.events.has(event)) { if (!this.events.has(event)) {
this.events.set(event, new Set()); 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); return () => this.off(event, callback);
} }
off<T = unknown>(event: string, callback: EventCallback<T>): void { off(event: string, callback: EventCallback): void {
const callbacks = this.events.get(event); const callbacks = this.events.get(event);
if (!callbacks) { if (!callbacks) {
return; return;
} }
callbacks.delete(callback as EventCallback); callbacks.delete(callback);
if (callbacks.size === 0) { if (callbacks.size === 0) {
this.events.delete(event); this.events.delete(event);
} }
} }
once<T = unknown>(event: string, callback: EventCallback<T>): () => void { once(event: string, callback: EventCallback): () => void {
const wrapped: EventCallback<T> = (payload) => { const wrapped: EventCallback = (...args) => {
callback(payload); try {
this.off(event, wrapped); callback(...args);
} finally {
this.off(event, wrapped);
}
}; };
return this.on(event, wrapped); return this.on(event, wrapped);
} }
emit<T = unknown>(event: string, payload: T): void { emit(event: string, ...args: unknown[]): void {
const callbacks = this.events.get(event); const callbacks = this.events.get(event);
if (!callbacks) { if (!callbacks) {
return; return;
} }
callbacks.forEach((cb) => { callbacks.forEach((cb) => {
cb(payload); try {
cb(...args);
} catch (error) {
console.error(`RuntimeEvents: listener failed for "${event}"`, error);
}
}); });
} }

39
tests/kernel/runtime-events.test.ts

@ -34,17 +34,17 @@ vi.mock("@/scene/SceneManager", () => {
}); });
describe("RuntimeEvents", () => { describe("RuntimeEvents", () => {
it("supports on/off/emit", () => { it("supports on/off/emit with variadic args", () => {
const events = new RuntimeEvents(); const events = new RuntimeEvents();
const handler = vi.fn(); const handler = vi.fn();
events.on("runtime:ready", handler); 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.off("runtime:ready", handler);
events.emit("runtime:ready", { startedAt: 2 }); events.emit("runtime:ready", { startedAt: 2 });
expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ startedAt: 1 }); expect(handler).toHaveBeenCalledWith({ startedAt: 1 }, "extra", 42);
}); });
it("supports once subscriptions", () => { it("supports once subscriptions", () => {
@ -58,6 +58,39 @@ describe("RuntimeEvents", () => {
expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ current: "a", previous: undefined }); 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", () => { describe("AppRuntime plugin lifecycle", () => {

Loading…
Cancel
Save