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 _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;

8
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<void> {
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) {

30
src/kernel/RuntimeEvents.ts

@ -1,51 +1,59 @@
type EventCallback<T = unknown> = (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<string, Set<EventCallback>>();
on<T = unknown>(event: string, callback: EventCallback<T>): () => 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<T = unknown>(event: string, callback: EventCallback<T>): 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<T = unknown>(event: string, callback: EventCallback<T>): () => void {
const wrapped: EventCallback<T> = (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<T = unknown>(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);
}
});
}

39
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", () => {

Loading…
Cancel
Save