fix(sdk): release lifecycle validation modules

This commit is contained in:
2026-09-05 01:07:20 +08:00
parent f2823c398a
commit f25211c64e
4 changed files with 88 additions and 12 deletions
@@ -34,7 +34,12 @@ export function assertGameEntry(entry: GameEntry): void {
if (typeof entry.createModule !== 'function') { if (typeof entry.createModule !== 'function') {
throw new Error('GameEntry createModule must be a function'); throw new Error('GameEntry createModule must be a function');
} }
assertGameModule(entry.createModule()); const module = entry.createModule();
try {
assertGameModule(module);
} finally {
disposeValidationModule(module);
}
} }
/** Owns one isolated game module for one room at a time. */ /** Owns one isolated game module for one room at a time. */
@@ -59,15 +64,20 @@ export class GameSessionHost {
} }
this.currentState = 'attaching'; this.currentState = 'attaching';
let module: GameModule | undefined;
try { try {
const module = this.entry.createModule(); module = this.entry.createModule();
assertGameModule(module); assertGameModule(module);
this.module = module; this.module = module;
module.attach(host); module.attach(host);
this.currentState = 'active'; this.currentState = 'active';
} catch (error) { } catch (error) {
this.module = undefined; this.module = undefined;
this.currentState = 'idle'; try {
module?.dispose();
} finally {
this.currentState = 'idle';
}
throw error; throw error;
} }
} }
@@ -163,3 +173,9 @@ function assertGameModule(module: unknown): asserts module is GameModule {
} }
} }
} }
function disposeValidationModule(module: unknown): void {
if (!module || typeof module !== 'object') return;
const dispose = (module as Record<string, unknown>).dispose;
if (typeof dispose === 'function') dispose.call(module);
}
@@ -9,6 +9,7 @@ import { assertGameEntry, GameSessionHost } from '../runtime/game-session-host.t
export interface GameContractHost { export interface GameContractHost {
readonly host: GameHost; readonly host: GameHost;
invalidate(): void; invalidate(): void;
assertRestored(deskinfo: unknown): void;
} }
export type GameContractHostFactory = () => GameContractHost; export type GameContractHostFactory = () => GameContractHost;
@@ -24,8 +25,14 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF
} }
const firstFactoryModule = entry.createModule(); const firstFactoryModule = entry.createModule();
const secondFactoryModule = entry.createModule(); let secondFactoryModule: ReturnType<GameEntry['createModule']> | undefined;
assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules'); try {
secondFactoryModule = entry.createModule();
assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules');
} finally {
firstFactoryModule.dispose();
if (secondFactoryModule && secondFactoryModule !== firstFactoryModule) secondFactoryModule.dispose();
}
const sessions = new GameSessionHost(entry); const sessions = new GameSessionHost(entry);
runCycle(sessions, makeHost(), entry, 1); runCycle(sessions, makeHost(), entry, 1);
@@ -49,6 +56,7 @@ function runCycle(
/route/, /route/,
); );
sessions.restore(deskinfo); sessions.restore(deskinfo);
contractHost.assertRestored(deskinfo);
sessions.close(); sessions.close();
contractHost.invalidate(); contractHost.invalidate();
} }
@@ -14,6 +14,7 @@ import {
test('assertGameContract runs the reusable isolated session conformance suite', () => { test('assertGameContract runs the reusable isolated session conformance suite', () => {
let invalidations = 0; let invalidations = 0;
let nextModuleId = 0; let nextModuleId = 0;
const restoredDeskinfo: unknown[] = [];
const entry: GameEntry = { const entry: GameEntry = {
key: 'fixture', key: 'fixture',
gameId: 41, gameId: 41,
@@ -23,15 +24,19 @@ test('assertGameContract runs the reusable isolated session conformance suite',
if (seats === 2 || seats === 4 || seats === 10) return seats; if (seats === 2 || seats === 4 || seats === 10) return seats;
return 4; return 4;
}, },
createModule: () => makeModule(++nextModuleId), createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }),
}; };
assert.doesNotThrow(() => assertGameContract(entry, makeConformanceHost(() => { invalidations++; }))); assert.doesNotThrow(() => assertGameContract(
entry,
makeConformanceHost(() => { invalidations++; }, restoredDeskinfo),
));
assert.equal(invalidations, 2); assert.equal(invalidations, 2);
assert.equal(restoredDeskinfo.length, 0);
assert.ok(nextModuleId >= 2); assert.ok(nextModuleId >= 2);
}); });
function makeModule(id: number): GameModule { function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule {
let host: GameHost | undefined; let host: GameHost | undefined;
let restored: unknown; let restored: unknown;
let eventCount = 0; let eventCount = 0;
@@ -50,8 +55,16 @@ function makeModule(id: number): GameModule {
assert.equal(typeof data.cycle, 'number'); assert.equal(typeof data.cycle, 'number');
messageCycle = data.cycle; messageCycle = data.cycle;
}, },
restore(deskinfo) { restored = deskinfo; }, restore(deskinfo) {
restored = deskinfo;
onRestore(deskinfo);
},
dispose() { dispose() {
if (!host) {
assert.equal(eventCount, 0);
assert.equal(restored, undefined);
return;
}
assert.ok(host); assert.ok(host);
assert.equal(eventCount, 1); assert.equal(eventCount, 1);
assert.deepEqual(restored, deskinfoFor(messageCycle!)); assert.deepEqual(restored, deskinfoFor(messageCycle!));
@@ -63,7 +76,10 @@ function deskinfoFor(cycle: number): { readonly cycle: number } {
return { cycle }; return { cycle };
} }
function makeConformanceHost(onInvalidate: () => void): () => GameContractHost { function makeConformanceHost(
onInvalidate: () => void,
restoredDeskinfo: unknown[],
): () => GameContractHost {
return () => { return () => {
const snapshot: PlatformGameSnapshot = { const snapshot: PlatformGameSnapshot = {
connection: { phase: 'connected' }, connection: { phase: 'connected' },
@@ -80,6 +96,9 @@ function makeConformanceHost(onInvalidate: () => void): () => GameContractHost {
execute: () => {}, execute: () => {},
}, },
invalidate: onInvalidate, invalidate: onInvalidate,
assertRestored(deskinfo) {
assert.equal(restoredDeskinfo.shift(), deskinfo);
},
}; };
}; };
} }
@@ -40,15 +40,21 @@ function makeEntry(createModule: () => GameModule): GameEntry {
} }
function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule { function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule {
let attached = false;
return { return {
attach: () => log.push('attach'), attach: () => {
attached = true;
log.push('attach');
},
handlePlatformEvent: (event) => log.push(`event:${event.type}`), handlePlatformEvent: (event) => log.push(`event:${event.type}`),
handleGameMessage: (message) => log.push(`message:${message.rpc}`), handleGameMessage: (message) => log.push(`message:${message.rpc}`),
restore: (deskinfo) => { restore: (deskinfo) => {
log.push('restore'); log.push('restore');
onRestore?.(deskinfo); onRestore?.(deskinfo);
}, },
dispose: () => log.push('dispose'), dispose: () => {
if (attached) log.push('dispose');
},
}; };
} }
@@ -65,6 +71,18 @@ test('assertGameEntry rejects invalid identity, routes, seat counts, and module
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/); assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
}); });
test('assertGameEntry disposes its validation module exactly once', () => {
let disposals = 0;
const entry = makeEntry(() => ({
...makeModule([]),
dispose: () => { disposals++; },
}));
assert.doesNotThrow(() => assertGameEntry(entry));
assert.equal(disposals, 1);
});
test('GameSessionHost delivers room events and deskinfo to one active module in order', () => { test('GameSessionHost delivers room events and deskinfo to one active module in order', () => {
const log: string[] = []; const log: string[] = [];
const deskinfo = { turn: 7 }; const deskinfo = { turn: 7 };
@@ -120,6 +138,21 @@ test('GameSessionHost rejects illegal state transitions and mismatched game rout
assert.deepEqual(log, ['attach', 'message:draw']); assert.deepEqual(log, ['attach', 'message:draw']);
}); });
test('GameSessionHost disposes a module when attachment throws and returns to idle', () => {
let disposals = 0;
const sessions = new GameSessionHost(makeEntry(() => ({
...makeModule([]),
attach: () => { throw new Error('attach failed'); },
dispose: () => { disposals++; },
})));
disposals = 0;
assert.throws(() => sessions.open(makeHost()), /attach failed/);
assert.equal(disposals, 1);
assert.equal(sessions.state, 'idle');
});
test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => { test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => {
const log: string[] = []; const log: string[] = [];
const sessions = new GameSessionHost(makeEntry(() => makeModule(log))); const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));