fix(platform): harden runtime lifecycle cleanup

This commit is contained in:
2026-09-05 09:40:00 +08:00
parent 3a9c5d6fdb
commit 3963b3b791
2 changed files with 638 additions and 16 deletions
@@ -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]);
}
}