feat: bound local platform login diagnostics and wire scope
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import type { GameEntry } from '../../framework/sdk/contracts/game-entry.ts';
|
||||||
|
import type { EvidenceSink } from './local-login-transport.ts';
|
||||||
|
|
||||||
|
/** Satisfies entry validation while making every actual game operation a scope error. */
|
||||||
|
export function createDiagnosticGameEntry(gameId: string | number, record: EvidenceSink): GameEntry {
|
||||||
|
let nextInstanceId = 0;
|
||||||
|
function violation(operation: string, instanceId?: number): never {
|
||||||
|
record({ kind: 'scope-violation', data: { operation, instanceId } });
|
||||||
|
throw new Error(`Local platform login scope forbids ${operation}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: 'local-platform-login',
|
||||||
|
route: 'local-platform-login',
|
||||||
|
gameId,
|
||||||
|
resolveSeatCount: () => violation('resolveSeatCount'),
|
||||||
|
createModule() {
|
||||||
|
const instanceId = ++nextInstanceId;
|
||||||
|
let disposed = false;
|
||||||
|
record({ kind: 'diagnostic-module', data: { instanceId, state: 'created' } });
|
||||||
|
return {
|
||||||
|
attach: () => violation('attach', instanceId),
|
||||||
|
restore: () => violation('restore', instanceId),
|
||||||
|
handlePlatformEvent: () => violation('handlePlatformEvent', instanceId),
|
||||||
|
handleGameMessage: () => violation('handleGameMessage', instanceId),
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
record({ kind: 'diagnostic-module', data: { instanceId, state: 'disposed' } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
import type { LocalStartupContext } from '../../framework/config/local-startup.ts';
|
||||||
|
import type { Transport } from '../../framework/net/transport.ts';
|
||||||
|
import { WireClient, type WireEvent } from '../../framework/net/wire-client.ts';
|
||||||
|
import type { PlatformWireClient } from '../../framework/platform/runtime.ts';
|
||||||
|
import { parseLoginResponse } from '../../framework/protocol/contracts/index.ts';
|
||||||
|
import type { EvidenceSink } from './local-login-transport.ts';
|
||||||
|
|
||||||
|
interface LocalLoginWireOptions {
|
||||||
|
context: LocalStartupContext;
|
||||||
|
transportFactory: () => Transport;
|
||||||
|
onFault: (error: unknown) => void;
|
||||||
|
record: EvidenceSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ES2020-compatible aggregate retaining original errors, including non-Error values. */
|
||||||
|
class LocalLoginWireFault extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
constructor(errors: readonly unknown[]) {
|
||||||
|
super('Local login Wire failed with additional cleanup or evidence errors');
|
||||||
|
this.name = 'LocalLoginWireFault';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalLoginWire(options: LocalLoginWireOptions): PlatformWireClient {
|
||||||
|
const wire = new WireClient({ servers: options.context.config.servers, transportFactory: options.transportFactory });
|
||||||
|
const listeners = new Set<(event: WireEvent) => void>();
|
||||||
|
let stopped = false;
|
||||||
|
let faulted = false;
|
||||||
|
let terminalDelivery = false;
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
unsubscribe();
|
||||||
|
listeners.clear();
|
||||||
|
wire.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error: unknown, operation?: string, event?: WireEvent): unknown {
|
||||||
|
if (faulted) return error;
|
||||||
|
faulted = true;
|
||||||
|
const errors = [error];
|
||||||
|
try { stop(); } catch (cleanupError) { errors.push(cleanupError); }
|
||||||
|
if (operation !== undefined) {
|
||||||
|
try { options.record({ kind: 'scope-violation', data: { operation, event } }); }
|
||||||
|
catch (recordError) { errors.push(recordError); }
|
||||||
|
}
|
||||||
|
const fault = errors.length === 1 ? error : new LocalLoginWireFault(errors);
|
||||||
|
options.onFault(fault);
|
||||||
|
return fault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeError(operation: string): Error {
|
||||||
|
return new Error(`Local platform login scope forbids ${operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertActive(): void {
|
||||||
|
if (stopped || faulted || terminalDelivery) throw new Error('Local login Wire is stopped or terminal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliver(event: WireEvent): void {
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
if (stopped || faulted) break;
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = wire.subscribe(event => {
|
||||||
|
if (stopped || faulted || terminalDelivery) return;
|
||||||
|
try {
|
||||||
|
options.record({ kind: 'wire-event', data: event });
|
||||||
|
if (stopped || faulted) return;
|
||||||
|
if (event.type !== 'message') { deliver(event); return; }
|
||||||
|
const { route, rpc, data } = event.message;
|
||||||
|
if (rpc === 'connect_agentserver' || rpc === 'connect_roomserver' || (route !== 'agent' && route !== 'platform')) {
|
||||||
|
fail(scopeError(`${route}/${rpc}`), `${route}/${rpc}`, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let terminalOperation: string | undefined;
|
||||||
|
if (route === 'agent' && rpc === 'player_login') {
|
||||||
|
const parsed = parseLoginResponse(data);
|
||||||
|
if (parsed.state === 0 && parsed.room !== null) {
|
||||||
|
fail(scopeError('login room recovery'), 'login room recovery', event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.state !== 0) terminalOperation = 'login rejected';
|
||||||
|
}
|
||||||
|
if (route === 'agent' && rpc === 'kick_server') terminalOperation = 'kick_server';
|
||||||
|
if (terminalOperation !== undefined) {
|
||||||
|
// Existing runtime failure display runs first, but cannot initiate more traffic.
|
||||||
|
terminalDelivery = true;
|
||||||
|
try { deliver(event); }
|
||||||
|
catch (error) { fail(error); return; }
|
||||||
|
fail(scopeError(terminalOperation), terminalOperation, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deliver(event);
|
||||||
|
} catch (error) { fail(error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
if (stopped || faulted) return () => {};
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
},
|
||||||
|
start() { assertActive(); wire.start(); },
|
||||||
|
send(envelope) {
|
||||||
|
assertActive();
|
||||||
|
if (envelope.route !== 'agent' || envelope.rpc !== 'player_login') {
|
||||||
|
throw fail(scopeError(`outbound ${envelope.route}/${envelope.rpc}`), `outbound ${envelope.route}/${envelope.rpc}`);
|
||||||
|
}
|
||||||
|
wire.send(envelope);
|
||||||
|
},
|
||||||
|
switchServer() { assertActive(); throw fail(scopeError('switchServer'), 'switchServer'); },
|
||||||
|
reconnectCurrent() { assertActive(); wire.reconnectCurrent(); },
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
|
import type { GameHost, PlatformToGameEvent, GameServerMessage } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts')
|
||||||
|
.catch(error => assert.fail(`Diagnostic GameEntry implementation must exist: ${String(error)}`));
|
||||||
|
const evidence: { kind: string; data: unknown }[] = [];
|
||||||
|
return { entry: api.createDiagnosticGameEntry(101, event => evidence.push(event)), evidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real GameSessionHost validates and releases one diagnostic instance without entering a game', async () => {
|
||||||
|
const { entry, evidence } = await setup();
|
||||||
|
const host = new GameSessionHost(entry);
|
||||||
|
assert.equal(host.state, 'idle');
|
||||||
|
assert.equal(entry.key, 'local-platform-login');
|
||||||
|
assert.equal(entry.route, 'local-platform-login');
|
||||||
|
assert.equal(entry.gameId, 101);
|
||||||
|
assert.deepEqual(evidence, [
|
||||||
|
{ kind: 'diagnostic-module', data: { instanceId: 1, state: 'created' } },
|
||||||
|
{ kind: 'diagnostic-module', data: { instanceId: 1, state: 'disposed' } },
|
||||||
|
]);
|
||||||
|
host.dispose();
|
||||||
|
host.dispose();
|
||||||
|
assert.equal(evidence.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every game behavior records its operation and throws; dispose is idempotent', async () => {
|
||||||
|
const { entry, evidence } = await setup();
|
||||||
|
assert.throws(() => entry.resolveSeatCount([]), /resolveSeatCount/);
|
||||||
|
const module = entry.createModule();
|
||||||
|
for (const [operation, invoke] of [
|
||||||
|
['attach', () => module.attach({} as GameHost)],
|
||||||
|
['restore', () => module.restore({})],
|
||||||
|
['handlePlatformEvent', () => module.handlePlatformEvent({} as PlatformToGameEvent)],
|
||||||
|
['handleGameMessage', () => module.handleGameMessage({} as GameServerMessage)],
|
||||||
|
] as const) {
|
||||||
|
assert.throws(invoke, new RegExp(operation));
|
||||||
|
assert.equal(evidence[evidence.length - 1]?.kind, 'scope-violation');
|
||||||
|
assert.equal((evidence[evidence.length - 1]?.data as { operation: string }).operation, operation);
|
||||||
|
}
|
||||||
|
module.dispose();
|
||||||
|
module.dispose();
|
||||||
|
entry.createModule().dispose();
|
||||||
|
const lifecycle = evidence.filter(event => event.kind === 'diagnostic-module');
|
||||||
|
assert.deepEqual(lifecycle.map(event => event.data), [
|
||||||
|
{ instanceId: 1, state: 'created' }, { instanceId: 1, state: 'disposed' },
|
||||||
|
{ instanceId: 2, state: 'created' }, { instanceId: 2, state: 'disposed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||||
|
import { RECONNECT_INTERVAL_MS } from '../../YouleNexus/assets/framework/core/constants.ts';
|
||||||
|
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||||
|
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
||||||
|
import { FakeTransport } from '../helpers/fake-transport.ts';
|
||||||
|
|
||||||
|
const login: OutboundEnvelope = { app: 'youle', route: 'agent', rpc: 'player_login', data: { gameid: 101 } };
|
||||||
|
function fixture(name: string) {
|
||||||
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||||
|
}
|
||||||
|
async function setup(recordHook?: (event: { kind: string; data: unknown }) => void) {
|
||||||
|
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts')
|
||||||
|
.catch(error => assert.fail(`Local login Wire implementation must exist: ${String(error)}`));
|
||||||
|
const context = await resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local',
|
||||||
|
fetcher: { async fetch(): Promise<never> { throw new Error('unexpected fetch'); } } });
|
||||||
|
const transports: FakeTransport[] = [];
|
||||||
|
const events: WireEvent[] = [];
|
||||||
|
const evidence: { kind: string; data: unknown }[] = [];
|
||||||
|
const faults: unknown[] = [];
|
||||||
|
const order: string[] = [];
|
||||||
|
const wire = api.createLocalLoginWire({ context,
|
||||||
|
transportFactory: () => { const transport = new FakeTransport(); transports.push(transport); return transport; },
|
||||||
|
record: event => { evidence.push(event); recordHook?.(event); },
|
||||||
|
onFault: error => { order.push('fault'); faults.push(error); assert.equal(transports[0]?.closeCalls, 1); },
|
||||||
|
});
|
||||||
|
wire.subscribe(event => { events.push(event); order.push(event.type === 'message' ? event.message.rpc : event.type); });
|
||||||
|
const push = async (message: unknown) => { transports[0]!.serverPush(JSON.stringify(message)); await transports[0]!.flush(); };
|
||||||
|
return { wire, context, transports, events, evidence, faults, order, push };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('start is not open; only original lobby and ordinary agent/platform events are forwarded', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop());
|
||||||
|
h.wire.start();
|
||||||
|
assert.equal(h.events.length, 0);
|
||||||
|
await h.transports[0]!.flush();
|
||||||
|
let unsubscribed = 0;
|
||||||
|
const unsubscribe = h.wire.subscribe(() => unsubscribed++); unsubscribe(); unsubscribe();
|
||||||
|
await h.push(fixture('player-login-success.json'));
|
||||||
|
await h.push({ route: 'agent', rpc: 'show_message', data: { msg: 'hello' } });
|
||||||
|
await h.push({ route: 'platform', rpc: 'notice', data: {} });
|
||||||
|
assert.equal(unsubscribed, 0);
|
||||||
|
assert.equal(h.events.filter(event => event.type === 'message').length, 3);
|
||||||
|
for (const event of h.events) assert.ok(h.evidence.some(item => item.kind === 'wire-event' && item.data === event));
|
||||||
|
assert.deepEqual(h.faults, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, message, error] of [
|
||||||
|
['room recovery', fixture('player-login-room.json'), /login room recovery/],
|
||||||
|
['malformed login', { route: 'agent', rpc: 'player_login', data: { state: 0 } }, /\$\.data\.playerid/],
|
||||||
|
['room route', { route: 'room', rpc: 'notice', data: {} }, /room/],
|
||||||
|
['game route', { route: 'mahjong', rpc: 'notice', data: {} }, /mahjong/],
|
||||||
|
['agent switch', { route: 'agent', rpc: 'connect_agentserver', data: {} }, /connect_agentserver/],
|
||||||
|
['platform switch', { route: 'platform', rpc: 'connect_roomserver', data: {} }, /connect_roomserver/],
|
||||||
|
] as const) test(`${name} stops before downstream delivery and remains terminal`, async t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
await h.push(message);
|
||||||
|
assert.equal(h.faults.length, 1); assert.match(String(h.faults[0]), error);
|
||||||
|
assert.equal(h.events.length, 1);
|
||||||
|
await h.push(fixture('player-login-success.json'));
|
||||||
|
assert.throws(() => h.wire.reconnectCurrent(), /stopped|terminal/);
|
||||||
|
assert.throws(() => h.wire.start(), /stopped|terminal/);
|
||||||
|
h.wire.stop(); h.wire.stop(); t.mock.timers.tick(60000);
|
||||||
|
assert.equal(h.transports.length, 1); assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const message of [
|
||||||
|
{ route: 'agent', rpc: 'player_login', data: { state: 1 } },
|
||||||
|
{ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } },
|
||||||
|
]) test(`${message.rpc} failure reaches existing handlers before host notification`, async t => {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
h.wire.subscribe(event => { if (event.type === 'message') h.wire.stop(); });
|
||||||
|
await h.push(message);
|
||||||
|
assert.deepEqual(h.order, ['open', message.rpc, 'fault']);
|
||||||
|
assert.equal(h.faults.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outbound scope and explicit switch fail without sending or reconnecting', async t => {
|
||||||
|
for (const operation of ['send', 'switch'] as const) {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
assert.throws(() => operation === 'send'
|
||||||
|
? h.wire.send({ ...login, rpc: 'create_room' }) : h.wire.switchServer(h.context.config.servers[0]!), /scope|switchServer/);
|
||||||
|
assert.deepEqual(h.transports[0]!.sent, []); assert.equal(h.faults.length, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal network close reconnects with the same single login envelope', async t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop());
|
||||||
|
h.wire.subscribe(event => { if (event.type === 'open') h.wire.send(login); });
|
||||||
|
h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
h.transports[0]!.serverError(); await h.transports[0]!.flush(); t.mock.timers.tick(RECONNECT_INTERVAL_MS);
|
||||||
|
assert.equal(h.transports.length, 2); await h.transports[1]!.flush();
|
||||||
|
assert.equal(h.transports[0]!.sent[0], JSON.stringify(login));
|
||||||
|
assert.deepEqual(h.transports[1]!.sent, h.transports[0]!.sent); assert.deepEqual(h.faults, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope fault retains its primary error and independent cleanup error', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
const cleanup = new Error('cleanup failed');
|
||||||
|
h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; };
|
||||||
|
await h.push({ route: 'room', rpc: 'notice', data: {} });
|
||||||
|
assert.equal(h.faults.length, 1);
|
||||||
|
const errors = (h.faults[0] as { errors: unknown[] }).errors;
|
||||||
|
assert.match(String(errors[0]), /room/); assert.equal(errors[1], cleanup);
|
||||||
|
h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evidence failure preserves the exact thrown value plus cleanup error and prevents delivery', async t => {
|
||||||
|
const primary = { reason: 'evidence unavailable' };
|
||||||
|
const h = await setup(event => {
|
||||||
|
if (event.kind === 'wire-event' && (event.data as WireEvent).type === 'message') throw primary;
|
||||||
|
});
|
||||||
|
t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
const cleanup = new Error('close failed');
|
||||||
|
h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; };
|
||||||
|
await h.push(fixture('player-login-success.json'));
|
||||||
|
assert.deepEqual((h.faults[0] as { errors: unknown[] }).errors, [primary, cleanup]);
|
||||||
|
assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listener error is reported unchanged, stops remaining delivery, and stops only once', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
const primary = new Error('downstream failed');
|
||||||
|
h.wire.subscribe(() => { throw primary; });
|
||||||
|
let laterDeliveries = 0; h.wire.subscribe(() => laterDeliveries++);
|
||||||
|
await h.push(fixture('player-login-success.json'));
|
||||||
|
assert.equal(h.faults[0], primary); assert.equal(laterDeliveries, 0);
|
||||||
|
h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal callback cannot send or reconnect before runtime stop; host notification is single', async t => {
|
||||||
|
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||||
|
h.wire.subscribe(event => {
|
||||||
|
if (event.type !== 'message') return;
|
||||||
|
assert.throws(() => h.wire.send(login), /terminal/);
|
||||||
|
assert.throws(() => h.wire.reconnectCurrent(), /terminal/);
|
||||||
|
});
|
||||||
|
await h.push({ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } });
|
||||||
|
assert.equal(h.faults.length, 1); assert.deepEqual(h.transports[0]!.sent, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user