diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts index a943911..b6f6468 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts @@ -1,7 +1,7 @@ import type { GameHost } from '../sdk/contracts/index.ts'; import { GameSessionHost, - GameSessionOpenFault, + isGameSessionOpenFault, } from '../sdk/runtime/game-session-host.ts'; import { parseLoginResponse, @@ -170,7 +170,7 @@ export class RuntimeSession { }); if (reconnect.present) this.options.gameSession.restore(reconnect.value); } catch (error) { - const errors = error instanceof GameSessionOpenFault ? [...error.errors] : [error]; + const errors = isGameSessionOpenFault(error) ? [...error.errors] : [error]; this.throwFault(operation, [...errors, ...this.releaseGame()]); } } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts index ded9da3..e1bbe3e 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts @@ -7,6 +7,9 @@ import type { } from '../contracts/index.ts'; const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']); +const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for( + 'youle.framework.sdk.GameSessionOpenFault', +); export type GameSessionState = | 'idle' @@ -22,10 +25,33 @@ export class GameSessionOpenFault extends Error { constructor(primary: unknown, cleanup: unknown) { super('Game session open failed and module cleanup also failed'); this.name = 'GameSessionOpenFault'; + Object.defineProperty(this, GAME_SESSION_OPEN_FAULT_BRAND, { value: true }); 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. */ export function assertGameEntry(entry: GameEntry): void { assertNonEmptyText(entry.key, 'key'); @@ -195,3 +221,10 @@ function disposeModuleIfPresent(module: unknown): void { const dispose = (module as Record).dispose; 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; +} diff --git a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts index b209c5d..2fce325 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; import type { GameEntry, GameHost, @@ -8,7 +9,10 @@ import type { PlatformGameSnapshot, PlatformToGameEvent, } 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 { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.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], ); }); + +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); +}); diff --git a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts index 51afc26..4a7e821 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; import type { GameEntry, GameHost, @@ -11,7 +12,11 @@ import type { import { assertGameEntry, GameSessionHost, + GameSessionOpenFault, } 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 { const snapshot: PlatformGameSnapshot = { @@ -221,6 +226,61 @@ test('GameSessionHost preserves attach then dispose failures by identity and occ 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) + .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', () => { const log: string[] = []; const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));