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
@@ -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<string, unknown> {
return JSON.parse(
@@ -60,6 +62,42 @@ function runtimeConfig(gameid: string | number = '41'): 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 {
private nextHandle = 1;
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 {
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<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 () => {
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;
}
});