fix(platform): harden runtime lifecycle cleanup
This commit is contained in:
@@ -117,6 +117,7 @@ export class PlatformRuntime {
|
||||
private loginSceneShown = false;
|
||||
private fatalError: Error | null = null;
|
||||
private wireStopped = false;
|
||||
private startupGeneration = 0;
|
||||
|
||||
constructor(private readonly options: PlatformRuntimeOptions) {
|
||||
this.clock = options.clock ?? realClock;
|
||||
@@ -134,17 +135,26 @@ export class PlatformRuntime {
|
||||
if (this.lifecycle !== 'idle') {
|
||||
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
||||
}
|
||||
const generation = this.startupGeneration + 1;
|
||||
this.startupGeneration = generation;
|
||||
this.lifecycle = 'starting';
|
||||
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(
|
||||
'minimum-display',
|
||||
this.options.waitForMinimumDisplay,
|
||||
generation,
|
||||
);
|
||||
|
||||
try {
|
||||
this.requireActiveStartup(generation);
|
||||
const config = await this.options.resolveRuntimeConfig();
|
||||
if (this.fatalError !== null) throw this.fatalError;
|
||||
this.requireActiveStartup(generation);
|
||||
if (config.identity.gameid !== this.options.gameEntry.gameId) {
|
||||
throw new Error(
|
||||
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
|
||||
@@ -154,6 +164,7 @@ export class PlatformRuntime {
|
||||
}
|
||||
this.config = config;
|
||||
|
||||
this.requireActiveStartup(generation);
|
||||
const gameSession = new SceneOrderedGameSessionHost(
|
||||
this.options.gameEntry,
|
||||
this.options.scene,
|
||||
@@ -183,20 +194,28 @@ export class PlatformRuntime {
|
||||
},
|
||||
});
|
||||
const router = new Router({ session, platform: handlers, gameSession });
|
||||
this.requireActiveStartup(generation);
|
||||
const wire = this.options.createWireClient(config);
|
||||
|
||||
this.requireActiveStartup(generation);
|
||||
this.gameSession = gameSession;
|
||||
this.commands = commands;
|
||||
this.session = session;
|
||||
this.router = router;
|
||||
this.wire = wire;
|
||||
this.requireActiveStartup(generation);
|
||||
this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); });
|
||||
this.requireActiveStartup(generation);
|
||||
this.markReady('config');
|
||||
this.lifecycle = 'running';
|
||||
this.requireActiveStartup(generation);
|
||||
wire.start();
|
||||
await Promise.all([resourcesReady, minimumDisplayReady]);
|
||||
if (this.fatalError !== null) throw this.fatalError;
|
||||
this.requireActiveStartup(generation);
|
||||
} catch (error) {
|
||||
if (!this.isActiveStartup(generation)) {
|
||||
throw this.fatalError ?? error;
|
||||
}
|
||||
const fatal = this.showFatalOnce(error);
|
||||
throw fatal;
|
||||
}
|
||||
@@ -233,12 +252,13 @@ export class PlatformRuntime {
|
||||
|
||||
stop(): void {
|
||||
if (this.lifecycle === 'stopped') return;
|
||||
this.startupGeneration += 1;
|
||||
this.lifecycle = 'stopped';
|
||||
this.clearLoginGuard();
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
|
||||
const errors: unknown[] = [];
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
const unsubscribe = this.unsubscribeWire;
|
||||
this.unsubscribeWire = null;
|
||||
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
|
||||
@@ -261,12 +281,20 @@ export class PlatformRuntime {
|
||||
private beginReadyTask(
|
||||
name: ReadyGateName,
|
||||
task: () => Promise<void>,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
return Promise.resolve()
|
||||
.then(task)
|
||||
.then(() => {
|
||||
this.requireActiveStartup(generation);
|
||||
return task();
|
||||
})
|
||||
.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') {
|
||||
this.clearLoginGuard();
|
||||
}
|
||||
this.requireRouter().dispatch(event.message);
|
||||
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
||||
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);
|
||||
}
|
||||
break;
|
||||
case 'slow':
|
||||
case 'reconnecting':
|
||||
@@ -393,10 +426,10 @@ export class PlatformRuntime {
|
||||
|
||||
private stopForKick(data: unknown): void {
|
||||
this.lifecycle = 'kicked';
|
||||
this.clearLoginGuard();
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
const errors: unknown[] = [];
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||
this.throwCleanupErrors('kick', errors);
|
||||
@@ -440,8 +473,9 @@ export class PlatformRuntime {
|
||||
|
||||
private clearLoginGuard(): void {
|
||||
if (this.loginGuard === null) return;
|
||||
this.clock.clearTimeout(this.loginGuard);
|
||||
const handle = this.loginGuard;
|
||||
this.loginGuard = null;
|
||||
this.clock.clearTimeout(handle);
|
||||
}
|
||||
|
||||
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 {
|
||||
const error = value instanceof Error ? value : new Error(String(value));
|
||||
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.clearLoginGuard();
|
||||
this.options.scene.showFatal(error);
|
||||
return error;
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user