From 3963b3b791a5c93edf702add183deba91273082b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 09:40:00 +0800 Subject: [PATCH] fix(platform): harden runtime lifecycle cleanup --- .../assets/framework/platform/runtime.ts | 120 +++- .../framework-tests/platform/runtime.test.ts | 534 ++++++++++++++++++ 2 files changed, 638 insertions(+), 16 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts index 0e0f503..cb8c7a9 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts @@ -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, + generation: number, ): Promise { 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]); } } diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts index 2f90cce..ea8914b 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -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 { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts'; +import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; import type { GameEntry, GameHost, @@ -16,6 +17,7 @@ import { type PlatformWireClient, } from '../../YouleNexus/assets/framework/platform/runtime.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 { return JSON.parse( @@ -60,6 +62,42 @@ function runtimeConfig(gameid: string | number = '41'): RuntimeConfig { }) as RuntimeConfig; } +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +function deferred(): Deferred { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +function rejectedValue(promise: Promise): Promise { + 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 { private nextHandle = 1; readonly timers = new Map 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 { readonly sent: OutboundEnvelope[] = []; readonly switchCalls: string[] = []; @@ -226,6 +282,39 @@ function beginEntryRecording(entry: GameEntry): void { (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 () => { 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']); }); +test('stop while config is pending prevents every late startup continuation from reviving runtime', async () => { + const configReady = deferred(); + const resourcesReady = deferred(); + const minimumDisplayReady = deferred(); + 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(); + const minimumDisplayReady = deferred(); + const wireStarted = deferred(); + 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 () => { const setup = makeRuntime(); 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); }); +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 () => { const setup = makeRuntime(); 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.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; + } +});