fix(platform): harden runtime lifecycle cleanup
This commit is contained in:
@@ -117,6 +117,7 @@ export class PlatformRuntime {
|
|||||||
private loginSceneShown = false;
|
private loginSceneShown = false;
|
||||||
private fatalError: Error | null = null;
|
private fatalError: Error | null = null;
|
||||||
private wireStopped = false;
|
private wireStopped = false;
|
||||||
|
private startupGeneration = 0;
|
||||||
|
|
||||||
constructor(private readonly options: PlatformRuntimeOptions) {
|
constructor(private readonly options: PlatformRuntimeOptions) {
|
||||||
this.clock = options.clock ?? realClock;
|
this.clock = options.clock ?? realClock;
|
||||||
@@ -134,17 +135,26 @@ export class PlatformRuntime {
|
|||||||
if (this.lifecycle !== 'idle') {
|
if (this.lifecycle !== 'idle') {
|
||||||
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
||||||
}
|
}
|
||||||
|
const generation = this.startupGeneration + 1;
|
||||||
|
this.startupGeneration = generation;
|
||||||
this.lifecycle = 'starting';
|
this.lifecycle = 'starting';
|
||||||
this.options.scene.showLoading();
|
this.options.scene.showLoading();
|
||||||
const resourcesReady = this.beginReadyTask('resources', this.options.loadResources);
|
this.requireActiveStartup(generation);
|
||||||
|
const resourcesReady = this.beginReadyTask(
|
||||||
|
'resources',
|
||||||
|
this.options.loadResources,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
const minimumDisplayReady = this.beginReadyTask(
|
const minimumDisplayReady = this.beginReadyTask(
|
||||||
'minimum-display',
|
'minimum-display',
|
||||||
this.options.waitForMinimumDisplay,
|
this.options.waitForMinimumDisplay,
|
||||||
|
generation,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
const config = await this.options.resolveRuntimeConfig();
|
const config = await this.options.resolveRuntimeConfig();
|
||||||
if (this.fatalError !== null) throw this.fatalError;
|
this.requireActiveStartup(generation);
|
||||||
if (config.identity.gameid !== this.options.gameEntry.gameId) {
|
if (config.identity.gameid !== this.options.gameEntry.gameId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
|
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
|
||||||
@@ -154,6 +164,7 @@ export class PlatformRuntime {
|
|||||||
}
|
}
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
const gameSession = new SceneOrderedGameSessionHost(
|
const gameSession = new SceneOrderedGameSessionHost(
|
||||||
this.options.gameEntry,
|
this.options.gameEntry,
|
||||||
this.options.scene,
|
this.options.scene,
|
||||||
@@ -183,20 +194,28 @@ export class PlatformRuntime {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const router = new Router({ session, platform: handlers, gameSession });
|
const router = new Router({ session, platform: handlers, gameSession });
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
const wire = this.options.createWireClient(config);
|
const wire = this.options.createWireClient(config);
|
||||||
|
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
this.gameSession = gameSession;
|
this.gameSession = gameSession;
|
||||||
this.commands = commands;
|
this.commands = commands;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.router = router;
|
this.router = router;
|
||||||
this.wire = wire;
|
this.wire = wire;
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); });
|
this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); });
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
this.markReady('config');
|
this.markReady('config');
|
||||||
this.lifecycle = 'running';
|
this.lifecycle = 'running';
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
wire.start();
|
wire.start();
|
||||||
await Promise.all([resourcesReady, minimumDisplayReady]);
|
await Promise.all([resourcesReady, minimumDisplayReady]);
|
||||||
if (this.fatalError !== null) throw this.fatalError;
|
this.requireActiveStartup(generation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!this.isActiveStartup(generation)) {
|
||||||
|
throw this.fatalError ?? error;
|
||||||
|
}
|
||||||
const fatal = this.showFatalOnce(error);
|
const fatal = this.showFatalOnce(error);
|
||||||
throw fatal;
|
throw fatal;
|
||||||
}
|
}
|
||||||
@@ -233,12 +252,13 @@ export class PlatformRuntime {
|
|||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.lifecycle === 'stopped') return;
|
if (this.lifecycle === 'stopped') return;
|
||||||
|
this.startupGeneration += 1;
|
||||||
this.lifecycle = 'stopped';
|
this.lifecycle = 'stopped';
|
||||||
this.clearLoginGuard();
|
|
||||||
this.connectionIntent = { type: 'none' };
|
this.connectionIntent = { type: 'none' };
|
||||||
this.wireIsOpen = false;
|
this.wireIsOpen = false;
|
||||||
|
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
|
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||||
const unsubscribe = this.unsubscribeWire;
|
const unsubscribe = this.unsubscribeWire;
|
||||||
this.unsubscribeWire = null;
|
this.unsubscribeWire = null;
|
||||||
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
|
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
|
||||||
@@ -261,12 +281,20 @@ export class PlatformRuntime {
|
|||||||
private beginReadyTask(
|
private beginReadyTask(
|
||||||
name: ReadyGateName,
|
name: ReadyGateName,
|
||||||
task: () => Promise<void>,
|
task: () => Promise<void>,
|
||||||
|
generation: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
.then(task)
|
.then(() => {
|
||||||
|
this.requireActiveStartup(generation);
|
||||||
|
return task();
|
||||||
|
})
|
||||||
.then(
|
.then(
|
||||||
() => { this.markReady(name); },
|
() => {
|
||||||
(error: unknown) => { this.showFatalOnce(error); },
|
if (this.isActiveStartup(generation)) this.markReady(name);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (this.isActiveStartup(generation)) this.showFatalOnce(error);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +337,13 @@ export class PlatformRuntime {
|
|||||||
if (event.message.route === 'agent' && event.message.rpc === 'player_login') {
|
if (event.message.route === 'agent' && event.message.rpc === 'player_login') {
|
||||||
this.clearLoginGuard();
|
this.clearLoginGuard();
|
||||||
}
|
}
|
||||||
this.requireRouter().dispatch(event.message);
|
const stateBeforeDispatch = this.store.getState();
|
||||||
|
if (
|
||||||
|
this.requireRouter().dispatch(event.message) === 'handled-platform'
|
||||||
|
&& this.store.getState() !== stateBeforeDispatch
|
||||||
|
) {
|
||||||
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'slow':
|
case 'slow':
|
||||||
case 'reconnecting':
|
case 'reconnecting':
|
||||||
@@ -393,10 +426,10 @@ export class PlatformRuntime {
|
|||||||
|
|
||||||
private stopForKick(data: unknown): void {
|
private stopForKick(data: unknown): void {
|
||||||
this.lifecycle = 'kicked';
|
this.lifecycle = 'kicked';
|
||||||
this.clearLoginGuard();
|
|
||||||
this.connectionIntent = { type: 'none' };
|
this.connectionIntent = { type: 'none' };
|
||||||
this.wireIsOpen = false;
|
this.wireIsOpen = false;
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
|
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||||
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||||
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||||
this.throwCleanupErrors('kick', errors);
|
this.throwCleanupErrors('kick', errors);
|
||||||
@@ -440,8 +473,9 @@ export class PlatformRuntime {
|
|||||||
|
|
||||||
private clearLoginGuard(): void {
|
private clearLoginGuard(): void {
|
||||||
if (this.loginGuard === null) return;
|
if (this.loginGuard === null) return;
|
||||||
this.clock.clearTimeout(this.loginGuard);
|
const handle = this.loginGuard;
|
||||||
this.loginGuard = null;
|
this.loginGuard = null;
|
||||||
|
this.clock.clearTimeout(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireConfig(): RuntimeConfig {
|
private requireConfig(): RuntimeConfig {
|
||||||
@@ -492,13 +526,67 @@ export class PlatformRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isActiveStartup(generation: number): boolean {
|
||||||
|
return this.startupGeneration === generation
|
||||||
|
&& (this.lifecycle === 'starting' || this.lifecycle === 'running');
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireActiveStartup(generation: number): void {
|
||||||
|
if (this.isActiveStartup(generation)) return;
|
||||||
|
throw this.fatalError
|
||||||
|
?? new Error(`PlatformRuntime startup interrupted while ${this.lifecycle}`);
|
||||||
|
}
|
||||||
|
|
||||||
private showFatalOnce(value: unknown): Error {
|
private showFatalOnce(value: unknown): Error {
|
||||||
const error = value instanceof Error ? value : new Error(String(value));
|
|
||||||
if (this.fatalError !== null) return this.fatalError;
|
if (this.fatalError !== null) return this.fatalError;
|
||||||
this.fatalError = error;
|
const primary = value;
|
||||||
|
this.fatalError = this.failureError('fatal', primary, []);
|
||||||
|
this.startupGeneration += 1;
|
||||||
this.lifecycle = 'fatal';
|
this.lifecycle = 'fatal';
|
||||||
this.clearLoginGuard();
|
this.connectionIntent = { type: 'none' };
|
||||||
this.options.scene.showFatal(error);
|
this.wireIsOpen = false;
|
||||||
return error;
|
|
||||||
|
const cleanupErrors: unknown[] = [];
|
||||||
|
this.captureCleanupError(cleanupErrors, () => { this.clearLoginGuard(); });
|
||||||
|
|
||||||
|
const unsubscribe = this.unsubscribeWire;
|
||||||
|
this.unsubscribeWire = null;
|
||||||
|
if (unsubscribe !== null) this.captureCleanupError(cleanupErrors, unsubscribe);
|
||||||
|
|
||||||
|
const gameSession = this.gameSession;
|
||||||
|
if (gameSession !== null) {
|
||||||
|
this.captureCleanupError(cleanupErrors, () => { gameSession.dispose(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lease = this.activeGameHostLease;
|
||||||
|
this.activeGameHostLease = null;
|
||||||
|
if (lease !== null) {
|
||||||
|
this.captureCleanupError(cleanupErrors, () => { lease.invalidate(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captureCleanupError(cleanupErrors, () => { this.stopWireOnce(); });
|
||||||
|
const fatal = this.failureError('fatal', primary, cleanupErrors);
|
||||||
|
this.fatalError = fatal;
|
||||||
|
try {
|
||||||
|
this.options.scene.showFatal(fatal);
|
||||||
|
} catch (sceneError) {
|
||||||
|
const withSceneError = this.failureError(
|
||||||
|
'fatal',
|
||||||
|
primary,
|
||||||
|
[...cleanupErrors, sceneError],
|
||||||
|
);
|
||||||
|
this.fatalError = withSceneError;
|
||||||
|
return withSceneError;
|
||||||
|
}
|
||||||
|
return fatal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private failureError(
|
||||||
|
operation: string,
|
||||||
|
primary: unknown,
|
||||||
|
cleanupErrors: readonly unknown[],
|
||||||
|
): Error {
|
||||||
|
if (cleanupErrors.length === 0 && primary instanceof Error) return primary;
|
||||||
|
return new PlatformRuntimeCleanupError(operation, [primary, ...cleanupErrors]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/run
|
|||||||
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
||||||
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
||||||
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||||
|
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
|
||||||
import type {
|
import type {
|
||||||
GameEntry,
|
GameEntry,
|
||||||
GameHost,
|
GameHost,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
type PlatformWireClient,
|
type PlatformWireClient,
|
||||||
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
|
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
|
||||||
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
|
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
|
||||||
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
|
|
||||||
function fixture(name: string): Record<string, unknown> {
|
function fixture(name: string): Record<string, unknown> {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
@@ -60,6 +62,42 @@ function runtimeConfig(gameid: string | number = '41'): RuntimeConfig {
|
|||||||
}) as RuntimeConfig;
|
}) as RuntimeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
readonly promise: Promise<T>;
|
||||||
|
resolve(value: T): void;
|
||||||
|
reject(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolvePromise!: (value: T) => void;
|
||||||
|
let rejectPromise!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
return { promise, resolve: resolvePromise, reject: rejectPromise };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectedValue(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
return promise.then(
|
||||||
|
() => { throw new Error('Expected promise to reject'); },
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function thrownValue(operation: () => void): unknown {
|
||||||
|
let didThrow = false;
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
didThrow = true;
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
assert.equal(didThrow, true, 'Expected operation to throw');
|
||||||
|
return thrown;
|
||||||
|
}
|
||||||
|
|
||||||
class ManualClock implements Clock {
|
class ManualClock implements Clock {
|
||||||
private nextHandle = 1;
|
private nextHandle = 1;
|
||||||
readonly timers = new Map<number, { readonly fn: () => void; readonly ms: number }>();
|
readonly timers = new Map<number, { readonly fn: () => void; readonly ms: number }>();
|
||||||
@@ -83,6 +121,24 @@ class ManualClock implements Clock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ControlledClearClock extends ManualClock {
|
||||||
|
private failureEnabled = false;
|
||||||
|
private failure: unknown;
|
||||||
|
onFailingClear: () => void = () => {};
|
||||||
|
|
||||||
|
enableClearFailure(error: unknown): void {
|
||||||
|
this.failure = error;
|
||||||
|
this.failureEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle: unknown): void {
|
||||||
|
super.clearTimeout(handle);
|
||||||
|
if (!this.failureEnabled) return;
|
||||||
|
this.onFailingClear();
|
||||||
|
throw this.failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class RecordingWireClient implements PlatformWireClient {
|
class RecordingWireClient implements PlatformWireClient {
|
||||||
readonly sent: OutboundEnvelope[] = [];
|
readonly sent: OutboundEnvelope[] = [];
|
||||||
readonly switchCalls: string[] = [];
|
readonly switchCalls: string[] = [];
|
||||||
@@ -226,6 +282,39 @@ function beginEntryRecording(entry: GameEntry): void {
|
|||||||
(entry as GameEntry & { beginRecording(): void }).beginRecording();
|
(entry as GameEntry & { beginRecording(): void }).beginRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CapturedGameHost {
|
||||||
|
host: GameHost | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCleanupFailureEntry(
|
||||||
|
log: string[],
|
||||||
|
disposeFailure: unknown,
|
||||||
|
captured: CapturedGameHost,
|
||||||
|
): GameEntry {
|
||||||
|
let moduleNumber = 0;
|
||||||
|
return makeGameEntry({
|
||||||
|
createModule(): GameModule {
|
||||||
|
moduleNumber += 1;
|
||||||
|
const validationModule = moduleNumber === 1;
|
||||||
|
return {
|
||||||
|
attach(host) {
|
||||||
|
if (validationModule) return;
|
||||||
|
captured.host = host;
|
||||||
|
host.subscribe(() => {});
|
||||||
|
},
|
||||||
|
handlePlatformEvent() {},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore() {},
|
||||||
|
dispose() {
|
||||||
|
if (validationModule) return;
|
||||||
|
log.push('game:dispose');
|
||||||
|
throw disposeFailure;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('start resolves config, subscribes before connect, and never logs in without an account', async () => {
|
test('start resolves config, subscribes before connect, and never logs in without an account', async () => {
|
||||||
const setup = makeRuntime();
|
const setup = makeRuntime();
|
||||||
|
|
||||||
@@ -240,6 +329,197 @@ test('start resolves config, subscribes before connect, and never logs in withou
|
|||||||
assert.deepEqual(setup.scene.calls.map((call) => call.name), ['loading', 'login']);
|
assert.deepEqual(setup.scene.calls.map((call) => call.name), ['loading', 'login']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stop while config is pending prevents every late startup continuation from reviving runtime', async () => {
|
||||||
|
const configReady = deferred<RuntimeConfig>();
|
||||||
|
const resourcesReady = deferred<void>();
|
||||||
|
const minimumDisplayReady = deferred<void>();
|
||||||
|
const wire = new RecordingWireClient();
|
||||||
|
const scene = new RecordingScene();
|
||||||
|
let resourcesStarted = 0;
|
||||||
|
let minimumDisplayStarted = 0;
|
||||||
|
let wireFactories = 0;
|
||||||
|
const runtime = new PlatformRuntime({
|
||||||
|
gameEntry: makeGameEntry(),
|
||||||
|
resolveRuntimeConfig: () => configReady.promise,
|
||||||
|
createWireClient() {
|
||||||
|
wireFactories += 1;
|
||||||
|
return wire;
|
||||||
|
},
|
||||||
|
scene,
|
||||||
|
loadResources() {
|
||||||
|
resourcesStarted += 1;
|
||||||
|
return resourcesReady.promise;
|
||||||
|
},
|
||||||
|
waitForMinimumDisplay() {
|
||||||
|
minimumDisplayStarted += 1;
|
||||||
|
return minimumDisplayReady.promise;
|
||||||
|
},
|
||||||
|
getLoginDeviceSnapshot: () => DEVICE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const starting = runtime.start();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(resourcesStarted, 1);
|
||||||
|
assert.equal(minimumDisplayStarted, 1);
|
||||||
|
|
||||||
|
runtime.stop();
|
||||||
|
configReady.resolve(runtimeConfig());
|
||||||
|
resourcesReady.resolve(undefined);
|
||||||
|
minimumDisplayReady.resolve(undefined);
|
||||||
|
|
||||||
|
await assert.rejects(starting, /stopped/i);
|
||||||
|
await assert.rejects(() => runtime.start(), /stopped/i);
|
||||||
|
assert.equal(wireFactories, 0);
|
||||||
|
assert.deepEqual(wire.lifecycle, []);
|
||||||
|
assert.equal(runtime.ready, false);
|
||||||
|
assert.deepEqual(scene.calls.map((call) => call.name), ['loading']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscribe startup failure enters fatal once and stops the created wire without starting it', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
const primary = new Error('subscribe failed');
|
||||||
|
setup.wire.onAction = (name) => {
|
||||||
|
if (name === 'subscribe') throw primary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const thrown = await rejectedValue(setup.runtime.start());
|
||||||
|
|
||||||
|
assert.equal(thrown, primary);
|
||||||
|
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'stop']);
|
||||||
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
|
||||||
|
[primary],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wire start failure cleans every established layer and preserves cleanup error order', async () => {
|
||||||
|
const primary = new Error('wire start failed');
|
||||||
|
const unsubscribeFailure = { step: 'unsubscribe' };
|
||||||
|
const stopFailure = { step: 'wire.stop' };
|
||||||
|
const cleanupLog: string[] = [];
|
||||||
|
let moduleNumber = 0;
|
||||||
|
let activeHost: GameHost | null = null;
|
||||||
|
const entry = makeGameEntry({
|
||||||
|
createModule(): GameModule {
|
||||||
|
moduleNumber += 1;
|
||||||
|
const currentModule = moduleNumber;
|
||||||
|
return {
|
||||||
|
attach(host) {
|
||||||
|
if (currentModule === 1) return;
|
||||||
|
activeHost = host;
|
||||||
|
host.subscribe(() => {});
|
||||||
|
},
|
||||||
|
handlePlatformEvent() {},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore() {},
|
||||||
|
dispose() {
|
||||||
|
if (currentModule === 1) return;
|
||||||
|
cleanupLog.push('game:dispose');
|
||||||
|
throw undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
setup.wire.onAction = (name) => {
|
||||||
|
if (name === 'start') {
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: {
|
||||||
|
route: 'agent',
|
||||||
|
rpc: 'player_login',
|
||||||
|
data: fixture('player-login-room.json'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw primary;
|
||||||
|
}
|
||||||
|
if (name === 'unsubscribe') {
|
||||||
|
cleanupLog.push('wire:unsubscribe');
|
||||||
|
throw unsubscribeFailure;
|
||||||
|
}
|
||||||
|
if (name === 'stop') {
|
||||||
|
cleanupLog.push('wire:stop');
|
||||||
|
const host = activeHost;
|
||||||
|
assert.ok(host);
|
||||||
|
assert.throws(() => host.getSnapshot(), /disposed/i);
|
||||||
|
throw stopFailure;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const thrown = await rejectedValue(setup.runtime.start());
|
||||||
|
assert.ok(thrown instanceof Error);
|
||||||
|
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
|
||||||
|
|
||||||
|
assert.deepEqual(cleanupLog, ['wire:unsubscribe', 'game:dispose', 'wire:stop']);
|
||||||
|
assert.equal(errors.length, 4);
|
||||||
|
assert.equal(errors[0], primary);
|
||||||
|
assert.equal(errors[1], unsubscribeFailure);
|
||||||
|
assert.equal(errors[2], undefined);
|
||||||
|
assert.equal(errors[3], stopFailure);
|
||||||
|
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 1);
|
||||||
|
assert.equal(
|
||||||
|
setup.scene.calls.find((call) => call.name === 'fatal')?.value,
|
||||||
|
thrown,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneCalls = setup.scene.calls.length;
|
||||||
|
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCalls);
|
||||||
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failedGate of ['resources', 'minimum-display'] as const) {
|
||||||
|
test(`${failedGate} failure after Wire start tears down once and stays fatal`, async () => {
|
||||||
|
const resourcesReady = deferred<void>();
|
||||||
|
const minimumDisplayReady = deferred<void>();
|
||||||
|
const wireStarted = deferred<void>();
|
||||||
|
const primary = new Error(`${failedGate} failed`);
|
||||||
|
const setup = makeRuntime();
|
||||||
|
const runtime = new PlatformRuntime({
|
||||||
|
gameEntry: makeGameEntry(),
|
||||||
|
resolveRuntimeConfig: async () => runtimeConfig(),
|
||||||
|
createWireClient: () => setup.wire,
|
||||||
|
scene: setup.scene,
|
||||||
|
loadResources: () => resourcesReady.promise,
|
||||||
|
waitForMinimumDisplay: () => minimumDisplayReady.promise,
|
||||||
|
getLoginDeviceSnapshot: () => DEVICE,
|
||||||
|
clock: setup.clock,
|
||||||
|
});
|
||||||
|
setup.wire.onAction = (name) => {
|
||||||
|
if (name === 'start') wireStarted.resolve(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const starting = runtime.start();
|
||||||
|
await wireStarted.promise;
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
if (failedGate === 'resources') {
|
||||||
|
resourcesReady.reject(primary);
|
||||||
|
minimumDisplayReady.resolve(undefined);
|
||||||
|
} else {
|
||||||
|
resourcesReady.resolve(undefined);
|
||||||
|
minimumDisplayReady.reject(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thrown = await rejectedValue(starting);
|
||||||
|
assert.equal(thrown, primary);
|
||||||
|
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']);
|
||||||
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
|
||||||
|
[primary],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sceneCalls = setup.scene.calls.length;
|
||||||
|
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCalls);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('login sends one exact request and an ordinary reopen resends the same data with a four-second guard', async () => {
|
test('login sends one exact request and an ordinary reopen resends the same data with a four-second guard', async () => {
|
||||||
const setup = makeRuntime();
|
const setup = makeRuntime();
|
||||||
await setup.runtime.start();
|
await setup.runtime.start();
|
||||||
@@ -517,6 +797,69 @@ test('a game rpc named like a platform rpc cannot drive platform scenes', async
|
|||||||
assert.equal(setup.scene.calls.length, sceneCalls);
|
assert.equal(setup.scene.calls.length, sceneCalls);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('login gate routes each ignored room message once with zero runtime side effects', async () => {
|
||||||
|
const originalDispatch = Router.prototype.dispatch;
|
||||||
|
let routerDispatches = 0;
|
||||||
|
Router.prototype.dispatch = function dispatchWithCount(message) {
|
||||||
|
routerDispatches += 1;
|
||||||
|
return originalDispatch.call(this, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const log: string[] = [];
|
||||||
|
const entry = makeLoggingEntry(log, [], []);
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
await setup.runtime.start();
|
||||||
|
beginEntryRecording(entry);
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
const stateBefore = setup.runtime.state;
|
||||||
|
const sceneCallsBefore = setup.scene.calls.length;
|
||||||
|
const lifecycleBefore = [...setup.wire.lifecycle];
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'room', rpc: 'self_exit_room', data: { seat: 1 } },
|
||||||
|
});
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(routerDispatches, 2);
|
||||||
|
assert.equal(setup.runtime.state, stateBefore);
|
||||||
|
assert.deepEqual(log, []);
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCallsBefore);
|
||||||
|
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0);
|
||||||
|
assert.deepEqual(setup.wire.lifecycle, lifecycleBefore);
|
||||||
|
assert.equal(setup.wire.stopCalls, 0);
|
||||||
|
} finally {
|
||||||
|
Router.prototype.dispatch = originalDispatch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a handled no-op room message cannot drive a scene without a state transition', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
const stateBefore = setup.runtime.state;
|
||||||
|
const sceneCallsBefore = setup.scene.calls.length;
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'room', rpc: 'self_exit_room', data: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.runtime.state, stateBefore);
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCallsBefore);
|
||||||
|
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('joinRoom, prepare, and exitRoom delegate through the authoritative platform commands', async () => {
|
test('joinRoom, prepare, and exitRoom delegate through the authoritative platform commands', async () => {
|
||||||
const setup = makeRuntime();
|
const setup = makeRuntime();
|
||||||
await setup.runtime.start();
|
await setup.runtime.start();
|
||||||
@@ -613,3 +956,194 @@ test('stop unsubscribes, disposes while the host is live, invalidates it, then s
|
|||||||
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
||||||
assert.equal(setup.wire.stopCalls, 1);
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stop continues after clearTimeout and every later cleanup failure in exact order', async () => {
|
||||||
|
const clearFailure = { step: 'clock.clearTimeout' };
|
||||||
|
const disposeFailure = { step: 'game.dispose' };
|
||||||
|
const leaseFailure = { step: 'lease.invalidate' };
|
||||||
|
const stopFailure = { step: 'wire.stop' };
|
||||||
|
const log: string[] = [];
|
||||||
|
const captured: CapturedGameHost = { host: null };
|
||||||
|
const clock = new ControlledClearClock();
|
||||||
|
const wire = new RecordingWireClient();
|
||||||
|
const scene = new RecordingScene();
|
||||||
|
const originalStoreSubscribe = PlatformStore.prototype.subscribe;
|
||||||
|
PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) {
|
||||||
|
const release = originalStoreSubscribe.call(this, listener);
|
||||||
|
return () => {
|
||||||
|
release();
|
||||||
|
log.push('lease:invalidate');
|
||||||
|
throw leaseFailure;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = new PlatformRuntime({
|
||||||
|
gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured),
|
||||||
|
resolveRuntimeConfig: async () => runtimeConfig(),
|
||||||
|
createWireClient: () => wire,
|
||||||
|
scene,
|
||||||
|
loadResources: async () => {},
|
||||||
|
waitForMinimumDisplay: async () => {},
|
||||||
|
getLoginDeviceSnapshot: () => DEVICE,
|
||||||
|
clock,
|
||||||
|
});
|
||||||
|
clock.onFailingClear = () => { log.push('clock:clear'); };
|
||||||
|
wire.onAction = (name) => {
|
||||||
|
if (name === 'unsubscribe') {
|
||||||
|
log.push('wire:unsubscribe');
|
||||||
|
throw undefined;
|
||||||
|
}
|
||||||
|
if (name === 'stop') {
|
||||||
|
log.push('wire:stop');
|
||||||
|
const host = captured.host;
|
||||||
|
assert.ok(host);
|
||||||
|
assert.throws(() => host.getSnapshot(), /disposed/i);
|
||||||
|
throw stopFailure;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
runtime.login(ACCOUNT);
|
||||||
|
wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
clock.enableClearFailure(clearFailure);
|
||||||
|
log.length = 0;
|
||||||
|
|
||||||
|
const thrown = thrownValue(() => { runtime.stop(); });
|
||||||
|
assert.ok(thrown instanceof Error);
|
||||||
|
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'clock:clear',
|
||||||
|
'wire:unsubscribe',
|
||||||
|
'game:dispose',
|
||||||
|
'lease:invalidate',
|
||||||
|
'wire:stop',
|
||||||
|
]);
|
||||||
|
assert.equal(errors.length, 5);
|
||||||
|
assert.equal(errors[0], clearFailure);
|
||||||
|
assert.equal(errors[1], undefined);
|
||||||
|
assert.equal(errors[2], disposeFailure);
|
||||||
|
assert.equal(errors[3], leaseFailure);
|
||||||
|
assert.equal(errors[4], stopFailure);
|
||||||
|
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 0);
|
||||||
|
|
||||||
|
const completedLog = [...log];
|
||||||
|
assert.doesNotThrow(() => { runtime.stop(); });
|
||||||
|
assert.deepEqual(log, completedLog);
|
||||||
|
assert.equal(wire.stopCalls, 1);
|
||||||
|
} finally {
|
||||||
|
PlatformStore.prototype.subscribe = originalStoreSubscribe;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kick completes close, invalidate, clear, kicked scene, and wire stop despite all failures', async () => {
|
||||||
|
const disposeFailure = { step: 'game.dispose' };
|
||||||
|
const leaseFailure = { step: 'lease.invalidate' };
|
||||||
|
const clearFailure = { step: 'clock.clearTimeout' };
|
||||||
|
const stopFailure = { step: 'wire.stop' };
|
||||||
|
const log: string[] = [];
|
||||||
|
const captured: CapturedGameHost = { host: null };
|
||||||
|
const clock = new ControlledClearClock();
|
||||||
|
const wire = new RecordingWireClient();
|
||||||
|
const scene = new RecordingScene();
|
||||||
|
const originalStoreSubscribe = PlatformStore.prototype.subscribe;
|
||||||
|
PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) {
|
||||||
|
const release = originalStoreSubscribe.call(this, listener);
|
||||||
|
return () => {
|
||||||
|
release();
|
||||||
|
log.push('lease:invalidate');
|
||||||
|
throw leaseFailure;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = new PlatformRuntime({
|
||||||
|
gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured),
|
||||||
|
resolveRuntimeConfig: async () => runtimeConfig(),
|
||||||
|
createWireClient: () => wire,
|
||||||
|
scene,
|
||||||
|
loadResources: async () => {},
|
||||||
|
waitForMinimumDisplay: async () => {},
|
||||||
|
getLoginDeviceSnapshot: () => DEVICE,
|
||||||
|
clock,
|
||||||
|
});
|
||||||
|
clock.onFailingClear = () => { log.push('clock:clear'); };
|
||||||
|
scene.onCall = (name) => {
|
||||||
|
if (name === 'kicked') {
|
||||||
|
log.push('scene:kicked');
|
||||||
|
throw undefined;
|
||||||
|
}
|
||||||
|
if (name === 'fatal') log.push('scene:fatal');
|
||||||
|
};
|
||||||
|
wire.onAction = (name) => {
|
||||||
|
if (name === 'unsubscribe') log.push('wire:unsubscribe');
|
||||||
|
if (name === 'stop') {
|
||||||
|
log.push('wire:stop');
|
||||||
|
const host = captured.host;
|
||||||
|
assert.ok(host);
|
||||||
|
assert.throws(() => host.getSnapshot(), /disposed/i);
|
||||||
|
throw stopFailure;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
runtime.login(ACCOUNT);
|
||||||
|
wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
clock.enableClearFailure(clearFailure);
|
||||||
|
log.length = 0;
|
||||||
|
const kick = { msg: 'terminal cleanup test', code: 1 };
|
||||||
|
|
||||||
|
wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'kick_server', data: kick },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'game:dispose',
|
||||||
|
'lease:invalidate',
|
||||||
|
'clock:clear',
|
||||||
|
'scene:kicked',
|
||||||
|
'wire:stop',
|
||||||
|
'wire:unsubscribe',
|
||||||
|
'scene:fatal',
|
||||||
|
]);
|
||||||
|
assert.equal(wire.stopCalls, 1);
|
||||||
|
assert.equal(scene.calls.filter((call) => call.name === 'kicked').length, 1);
|
||||||
|
assert.equal(scene.calls.find((call) => call.name === 'kicked')?.value, kick);
|
||||||
|
const fatal = scene.calls.find((call) => call.name === 'fatal')?.value;
|
||||||
|
assert.ok(fatal instanceof Error);
|
||||||
|
const sessionErrors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
|
||||||
|
assert.equal(sessionErrors.length, 3);
|
||||||
|
assert.equal(sessionErrors[0], disposeFailure);
|
||||||
|
assert.equal(sessionErrors[1], leaseFailure);
|
||||||
|
const kickCleanup = sessionErrors[2];
|
||||||
|
assert.ok(kickCleanup instanceof Error);
|
||||||
|
const kickErrors = (
|
||||||
|
kickCleanup as Error & { readonly errors: readonly unknown[] }
|
||||||
|
).errors;
|
||||||
|
assert.equal(kickErrors.length, 3);
|
||||||
|
assert.equal(kickErrors[0], clearFailure);
|
||||||
|
assert.equal(kickErrors[1], undefined);
|
||||||
|
assert.equal(kickErrors[2], stopFailure);
|
||||||
|
|
||||||
|
const completedLog = [...log];
|
||||||
|
assert.doesNotThrow(() => { runtime.stop(); });
|
||||||
|
assert.doesNotThrow(() => { runtime.stop(); });
|
||||||
|
assert.deepEqual(log, completedLog);
|
||||||
|
assert.equal(wire.stopCalls, 1);
|
||||||
|
} finally {
|
||||||
|
PlatformStore.prototype.subscribe = originalStoreSubscribe;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user