fix(platform): recognize session faults across realms
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { GameHost } from '../sdk/contracts/index.ts';
|
import type { GameHost } from '../sdk/contracts/index.ts';
|
||||||
import {
|
import {
|
||||||
GameSessionHost,
|
GameSessionHost,
|
||||||
GameSessionOpenFault,
|
isGameSessionOpenFault,
|
||||||
} from '../sdk/runtime/game-session-host.ts';
|
} from '../sdk/runtime/game-session-host.ts';
|
||||||
import {
|
import {
|
||||||
parseLoginResponse,
|
parseLoginResponse,
|
||||||
@@ -170,7 +170,7 @@ export class RuntimeSession {
|
|||||||
});
|
});
|
||||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errors = error instanceof GameSessionOpenFault ? [...error.errors] : [error];
|
const errors = isGameSessionOpenFault(error) ? [...error.errors] : [error];
|
||||||
this.throwFault(operation, [...errors, ...this.releaseGame()]);
|
this.throwFault(operation, [...errors, ...this.releaseGame()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
} from '../contracts/index.ts';
|
} from '../contracts/index.ts';
|
||||||
|
|
||||||
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
|
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
|
||||||
|
const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for(
|
||||||
|
'youle.framework.sdk.GameSessionOpenFault',
|
||||||
|
);
|
||||||
|
|
||||||
export type GameSessionState =
|
export type GameSessionState =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
@@ -22,10 +25,33 @@ export class GameSessionOpenFault extends Error {
|
|||||||
constructor(primary: unknown, cleanup: unknown) {
|
constructor(primary: unknown, cleanup: unknown) {
|
||||||
super('Game session open failed and module cleanup also failed');
|
super('Game session open failed and module cleanup also failed');
|
||||||
this.name = 'GameSessionOpenFault';
|
this.name = 'GameSessionOpenFault';
|
||||||
|
Object.defineProperty(this, GAME_SESSION_OPEN_FAULT_BRAND, { value: true });
|
||||||
this.errors = Object.freeze([primary, cleanup]);
|
this.errors = Object.freeze([primary, cleanup]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameSessionOpenFaultShape {
|
||||||
|
readonly errors: readonly [unknown, unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recognizes open faults across realms and duplicate module instances. */
|
||||||
|
export function isGameSessionOpenFault(value: unknown): value is GameSessionOpenFaultShape {
|
||||||
|
if (value === null || typeof value !== 'object') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brand = ownDataDescriptor(value, GAME_SESSION_OPEN_FAULT_BRAND);
|
||||||
|
const errors = ownDataDescriptor(value, 'errors');
|
||||||
|
if (brand?.value !== true || !errors || !Array.isArray(errors.value)) return false;
|
||||||
|
|
||||||
|
const errorList = errors.value;
|
||||||
|
return ownDataDescriptor(errorList, 'length')?.value === 2
|
||||||
|
&& ownDataDescriptor(errorList, '0') !== undefined
|
||||||
|
&& ownDataDescriptor(errorList, '1') !== undefined;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Validates the compile-time game identity before it reaches the runtime. */
|
/** Validates the compile-time game identity before it reaches the runtime. */
|
||||||
export function assertGameEntry(entry: GameEntry): void {
|
export function assertGameEntry(entry: GameEntry): void {
|
||||||
assertNonEmptyText(entry.key, 'key');
|
assertNonEmptyText(entry.key, 'key');
|
||||||
@@ -195,3 +221,10 @@ function disposeModuleIfPresent(module: unknown): void {
|
|||||||
const dispose = (module as Record<string, unknown>).dispose;
|
const dispose = (module as Record<string, unknown>).dispose;
|
||||||
if (typeof dispose === 'function') dispose.call(module);
|
if (typeof dispose === 'function') dispose.call(module);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ownDataDescriptor(target: object, key: PropertyKey): PropertyDescriptor | undefined {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
||||||
|
return descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
||||||
|
? descriptor
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
import type {
|
import type {
|
||||||
GameEntry,
|
GameEntry,
|
||||||
GameHost,
|
GameHost,
|
||||||
@@ -8,7 +9,10 @@ import type {
|
|||||||
PlatformGameSnapshot,
|
PlatformGameSnapshot,
|
||||||
PlatformToGameEvent,
|
PlatformToGameEvent,
|
||||||
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
import {
|
||||||
|
GameSessionHost,
|
||||||
|
GameSessionOpenFault,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
|
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
|
||||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
||||||
@@ -383,3 +387,84 @@ test('RuntimeSession flattens attach cleanup failures before appending lease inv
|
|||||||
[setup.errors.attach, setup.errors.dispose, setup.errors.invalidate],
|
[setup.errors.attach, setup.errors.dispose, setup.errors.invalidate],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RuntimeSession flattens a foreign-realm branded open fault before lease invalidation', () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
const primary = new Error('foreign primary');
|
||||||
|
const cleanup = new Error('foreign cleanup');
|
||||||
|
const foreignFault = runInNewContext(`(() => {
|
||||||
|
const fault = new Error('foreign open fault');
|
||||||
|
Object.defineProperties(fault, {
|
||||||
|
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true },
|
||||||
|
errors: { value: [primary, cleanup] },
|
||||||
|
});
|
||||||
|
return fault;
|
||||||
|
})()`, { primary, cleanup });
|
||||||
|
assert.equal(foreignFault instanceof GameSessionOpenFault, false);
|
||||||
|
setup.setFailures('invalidate');
|
||||||
|
setup.gameSession.open = () => { throw foreignFault; };
|
||||||
|
|
||||||
|
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
|
||||||
|
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.session.fault, error);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
(error as Error & { errors?: readonly unknown[] }).errors,
|
||||||
|
[primary, cleanup, setup.errors.invalidate],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs as single errors', () => {
|
||||||
|
const primary = new Error('user primary');
|
||||||
|
const cleanup = new Error('user cleanup');
|
||||||
|
const ordinaryAggregate = runInNewContext(
|
||||||
|
'new AggregateError([primary, cleanup], "user aggregate")',
|
||||||
|
{ primary, cleanup },
|
||||||
|
);
|
||||||
|
const unbranded = { errors: [primary, cleanup] };
|
||||||
|
let accessorReads = 0;
|
||||||
|
const accessorSpoof = new Error('accessor spoof');
|
||||||
|
Object.defineProperties(accessorSpoof, {
|
||||||
|
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true },
|
||||||
|
errors: {
|
||||||
|
get() {
|
||||||
|
accessorReads += 1;
|
||||||
|
throw new Error('errors getter must not run');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const brandAccessorSpoof = new Error('brand accessor spoof');
|
||||||
|
Object.defineProperties(brandAccessorSpoof, {
|
||||||
|
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: {
|
||||||
|
get() {
|
||||||
|
accessorReads += 1;
|
||||||
|
throw new Error('brand getter must not run');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: { value: [primary, cleanup] },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
ordinaryAggregate,
|
||||||
|
unbranded,
|
||||||
|
accessorSpoof,
|
||||||
|
brandAccessorSpoof,
|
||||||
|
]) {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.setFailures('invalidate');
|
||||||
|
setup.gameSession.open = () => { throw candidate; };
|
||||||
|
|
||||||
|
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
|
||||||
|
const errors = (error as Error & { errors?: readonly unknown[] }).errors;
|
||||||
|
|
||||||
|
assert.equal(errors?.length, 2);
|
||||||
|
assert.equal(errors?.[0], candidate);
|
||||||
|
assert.equal(errors?.[1], setup.errors.invalidate);
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
}
|
||||||
|
assert.equal(accessorReads, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
import type {
|
import type {
|
||||||
GameEntry,
|
GameEntry,
|
||||||
GameHost,
|
GameHost,
|
||||||
@@ -11,7 +12,11 @@ import type {
|
|||||||
import {
|
import {
|
||||||
assertGameEntry,
|
assertGameEntry,
|
||||||
GameSessionHost,
|
GameSessionHost,
|
||||||
|
GameSessionOpenFault,
|
||||||
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
|
import * as gameSessionRuntime from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
|
|
||||||
|
const OPEN_FAULT_BRAND_KEY = 'youle.framework.sdk.GameSessionOpenFault';
|
||||||
|
|
||||||
function makeHost(): GameHost {
|
function makeHost(): GameHost {
|
||||||
const snapshot: PlatformGameSnapshot = {
|
const snapshot: PlatformGameSnapshot = {
|
||||||
@@ -221,6 +226,61 @@ test('GameSessionHost preserves attach then dispose failures by identity and occ
|
|||||||
assert.equal(sessions.state, 'idle');
|
assert.equal(sessions.state, 'idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('open fault guard recognizes the global brand across realms without reading accessors', () => {
|
||||||
|
type OpenFaultShape = { readonly errors: readonly unknown[] };
|
||||||
|
type OpenFaultGuard = (value: unknown) => value is OpenFaultShape;
|
||||||
|
const exported = (gameSessionRuntime as unknown as Record<string, unknown>)
|
||||||
|
.isGameSessionOpenFault;
|
||||||
|
assert.equal(typeof exported, 'function');
|
||||||
|
const isGameSessionOpenFault = exported as OpenFaultGuard;
|
||||||
|
const primary = new Error('primary');
|
||||||
|
const cleanup = new Error('cleanup');
|
||||||
|
const sameRealm = new GameSessionOpenFault(primary, cleanup);
|
||||||
|
const foreignRealm = runInNewContext(`(() => {
|
||||||
|
const fault = new Error('foreign open fault');
|
||||||
|
Object.defineProperties(fault, {
|
||||||
|
[Symbol.for(${JSON.stringify(OPEN_FAULT_BRAND_KEY)})]: { value: true },
|
||||||
|
errors: { value: [primary, cleanup] },
|
||||||
|
});
|
||||||
|
return fault;
|
||||||
|
})()`, { primary, cleanup });
|
||||||
|
const ordinaryAggregate = runInNewContext(
|
||||||
|
'new AggregateError([primary, cleanup], "user aggregate")',
|
||||||
|
{ primary, cleanup },
|
||||||
|
);
|
||||||
|
const unbranded = { errors: [primary, cleanup] };
|
||||||
|
let accessorReads = 0;
|
||||||
|
const accessorSpoof = new Error('accessor spoof');
|
||||||
|
Object.defineProperties(accessorSpoof, {
|
||||||
|
[Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true },
|
||||||
|
errors: {
|
||||||
|
get() {
|
||||||
|
accessorReads += 1;
|
||||||
|
throw new Error('errors getter must not run');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const brandAccessorSpoof = new Error('brand accessor spoof');
|
||||||
|
Object.defineProperties(brandAccessorSpoof, {
|
||||||
|
[Symbol.for(OPEN_FAULT_BRAND_KEY)]: {
|
||||||
|
get() {
|
||||||
|
accessorReads += 1;
|
||||||
|
throw new Error('brand getter must not run');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: { value: [primary, cleanup] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(foreignRealm instanceof GameSessionOpenFault, false);
|
||||||
|
assert.equal(isGameSessionOpenFault(sameRealm), true);
|
||||||
|
assert.equal(isGameSessionOpenFault(foreignRealm), true);
|
||||||
|
assert.equal(isGameSessionOpenFault(ordinaryAggregate), false);
|
||||||
|
assert.equal(isGameSessionOpenFault(unbranded), false);
|
||||||
|
assert.equal(isGameSessionOpenFault(accessorSpoof), false);
|
||||||
|
assert.equal(isGameSessionOpenFault(brandAccessorSpoof), false);
|
||||||
|
assert.equal(accessorReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => {
|
test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => {
|
||||||
const log: string[] = [];
|
const log: string[] = [];
|
||||||
const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));
|
const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));
|
||||||
|
|||||||
Reference in New Issue
Block a user