fix(sdk): release lifecycle validation modules
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+10
-2
@@ -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)));
|
||||||
|
|||||||
Reference in New Issue
Block a user