From a383c0330e81b420b8d32d3e14c2e9d97ee8682b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 07:05:06 +0800 Subject: [PATCH] fix(platform): snapshot session fault errors safely --- .../framework/platform/runtime-session.ts | 10 +- .../sdk/runtime/game-session-host.ts | 49 +++++-- .../platform/runtime-session.test.ts | 50 +++++++ .../sdk/game-session-host.test.ts | 126 ++++++++++++++++-- 4 files changed, 209 insertions(+), 26 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts index b6f6468..b16679a 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 { + extractGameSessionOpenFaultErrors, GameSessionHost, - isGameSessionOpenFault, } from '../sdk/runtime/game-session-host.ts'; import { parseLoginResponse, @@ -170,8 +170,12 @@ export class RuntimeSession { }); if (reconnect.present) this.options.gameSession.restore(reconnect.value); } catch (error) { - const errors = isGameSessionOpenFault(error) ? [...error.errors] : [error]; - this.throwFault(operation, [...errors, ...this.releaseGame()]); + const openErrors = extractGameSessionOpenFaultErrors(error); + const errors = openErrors === null + ? [error] + : [openErrors[0], openErrors[1]]; + errors.push(...this.releaseGame()); + this.throwFault(operation, errors); } } 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 e1bbe3e..974f98f 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 @@ -19,39 +19,64 @@ export type GameSessionState = | 'disposing' | 'disposed'; +export type GameSessionOpenFaultErrors = readonly [unknown, unknown]; + export class GameSessionOpenFault extends Error { - readonly errors: readonly unknown[]; + declare readonly errors: GameSessionOpenFaultErrors; 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]); + const errors = Object.freeze([primary, cleanup] as [unknown, unknown]); + Object.defineProperties(this, { + [GAME_SESSION_OPEN_FAULT_BRAND]: { + value: true, + writable: false, + enumerable: false, + configurable: false, + }, + errors: { + value: errors, + writable: false, + enumerable: true, + configurable: false, + }, + }); } } export interface GameSessionOpenFaultShape { - readonly errors: readonly [unknown, unknown]; + readonly errors: GameSessionOpenFaultErrors; } -/** 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; +/** Snapshots open-fault errors across realms and duplicate module instances. */ +export function extractGameSessionOpenFaultErrors( + value: unknown, +): GameSessionOpenFaultErrors | null { + if (value === null || typeof value !== 'object') return null; 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; + if (brand?.value !== true || !errors || !Array.isArray(errors.value)) return null; const errorList = errors.value; - return ownDataDescriptor(errorList, 'length')?.value === 2 - && ownDataDescriptor(errorList, '0') !== undefined - && ownDataDescriptor(errorList, '1') !== undefined; + const length = ownDataDescriptor(errorList, 'length'); + const primary = ownDataDescriptor(errorList, '0'); + const cleanup = ownDataDescriptor(errorList, '1'); + if (length?.value !== 2 || !primary || !cleanup) return null; + + return Object.freeze([primary.value, cleanup.value] as [unknown, unknown]); } catch { - return false; + return null; } } +/** Recognizes open faults across realms and duplicate module instances. */ +export function isGameSessionOpenFault(value: unknown): value is GameSessionOpenFaultShape { + return extractGameSessionOpenFaultErrors(value) !== null; +} + /** Validates the compile-time game identity before it reaches the runtime. */ export function assertGameEntry(entry: GameEntry): void { assertNonEmptyText(entry.key, 'key'); diff --git a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts index 2fce325..484fc17 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -416,6 +416,41 @@ test('RuntimeSession flattens a foreign-realm branded open fault before lease in ); }); +test('RuntimeSession snapshots branded open errors without invoking their hostile iterator', () => { + const setup = makeLeaseSession(); + const iteratorReplacement = new Error('iterator replacement'); + let iteratorReads = 0; + const sourceErrors = [setup.errors.attach, setup.errors.dispose]; + Object.defineProperty(sourceErrors, Symbol.iterator, { + configurable: true, + get() { + iteratorReads += 1; + sourceErrors[0] = iteratorReplacement; + throw new Error('source iterator must not run'); + }, + }); + const brandedFault = new Error('hostile branded open fault'); + Object.defineProperties(brandedFault, { + [Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true }, + errors: { value: sourceErrors }, + }); + setup.setFailures('invalidate'); + setup.gameSession.open = () => { throw brandedFault; }; + + 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.equal(iteratorReads, 0); + assert.equal(sourceErrors[0], setup.errors.attach); + assert.deepEqual( + (error as Error & { errors?: readonly unknown[] }).errors, + [setup.errors.attach, setup.errors.dispose, 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'); @@ -445,12 +480,26 @@ test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs }, errors: { value: [primary, cleanup] }, }); + const descriptorTarget = new Error('descriptor failure'); + Object.defineProperties(descriptorTarget, { + [Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true }, + errors: { value: [primary, cleanup] }, + }); + let descriptorReads = 0; + const descriptorFailure = new Proxy(descriptorTarget, { + getOwnPropertyDescriptor(target, key) { + descriptorReads += 1; + if (key === 'errors') throw new Error('descriptor trap failed'); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); for (const candidate of [ ordinaryAggregate, unbranded, accessorSpoof, brandAccessorSpoof, + descriptorFailure, ]) { const setup = makeLeaseSession(); setup.setFailures('invalidate'); @@ -467,4 +516,5 @@ test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs assert.equal(setup.leases[0]?.invalidations, 1); } assert.equal(accessorReads, 0); + assert.equal(descriptorReads, 2); }); 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 4a7e821..53138b6 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -226,16 +226,65 @@ 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', () => { +test('GameSessionOpenFault locks its brand and frozen error tuple against later mutation', () => { + const primary = new Error('primary'); + const cleanup = new Error('cleanup'); + const replacement = new Error('replacement'); + const fault = new GameSessionOpenFault(primary, cleanup); + const brand = Symbol.for(OPEN_FAULT_BRAND_KEY); + const originalErrors = fault.errors; + + assert.deepEqual(Object.getOwnPropertyDescriptor(fault, brand), { + value: true, + writable: false, + enumerable: false, + configurable: false, + }); + assert.deepEqual(Object.getOwnPropertyDescriptor(fault, 'errors'), { + value: originalErrors, + writable: false, + enumerable: true, + configurable: false, + }); + assert.equal(Object.isFrozen(originalErrors), true); + + assert.throws(() => { + (fault as unknown as { errors: unknown[] }).errors = [replacement, cleanup]; + }, TypeError); + assert.throws(() => { + (originalErrors as unknown as unknown[])[0] = replacement; + }, TypeError); + assert.throws(() => { + Object.defineProperty(originalErrors, Symbol.iterator, { + value: function* hostileIterator() { yield replacement; }, + }); + }, TypeError); + assert.throws(() => { + (fault as unknown as { [key: symbol]: unknown })[brand] = false; + }, TypeError); + + assert.equal(fault.errors, originalErrors); + assert.equal(fault.errors[0], primary); + assert.equal(fault.errors[1], cleanup); + assert.equal((fault as unknown as { [key: symbol]: unknown })[brand], true); +}); + +test('open fault extractor snapshots branded faults across realms without reading hostile hooks', () => { 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; + type OpenFaultExtractor = (value: unknown) => readonly [unknown, unknown] | null; + const runtimeExports = gameSessionRuntime as unknown as Record; + const guardExport = runtimeExports.isGameSessionOpenFault; + const extractorExport = runtimeExports.extractGameSessionOpenFaultErrors; + assert.equal(typeof guardExport, 'function'); + assert.equal(typeof extractorExport, 'function'); + const isGameSessionOpenFault = guardExport as OpenFaultGuard; + const extractGameSessionOpenFaultErrors = extractorExport as OpenFaultExtractor; const primary = new Error('primary'); const cleanup = new Error('cleanup'); const sameRealm = new GameSessionOpenFault(primary, cleanup); + class OpenFaultSubclass extends GameSessionOpenFault {} + const subclass = new OpenFaultSubclass(primary, cleanup); const foreignRealm = runInNewContext(`(() => { const fault = new Error('foreign open fault'); Object.defineProperties(fault, { @@ -244,6 +293,11 @@ test('open fault guard recognizes the global brand across realms without reading }); return fault; })()`, { primary, cleanup }); + const duplicateModuleLike = new Error('duplicate module open fault'); + Object.defineProperties(duplicateModuleLike, { + [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, + errors: { value: [primary, cleanup] }, + }); const ordinaryAggregate = runInNewContext( 'new AggregateError([primary, cleanup], "user aggregate")', { primary, cleanup }, @@ -270,14 +324,64 @@ test('open fault guard recognizes the global brand across realms without reading }, errors: { value: [primary, cleanup] }, }); + const iteratorReplacement = new Error('iterator replacement'); + let iteratorReads = 0; + const hostileErrors = [primary, cleanup]; + Object.defineProperty(hostileErrors, Symbol.iterator, { + configurable: true, + get() { + iteratorReads += 1; + hostileErrors[0] = iteratorReplacement; + throw new Error('source iterator must not run'); + }, + }); + const hostileIteratorFault = new Error('hostile iterator fault'); + Object.defineProperties(hostileIteratorFault, { + [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, + errors: { value: hostileErrors }, + }); + const descriptorTarget = new Error('descriptor failure'); + Object.defineProperties(descriptorTarget, { + [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, + errors: { value: [primary, cleanup] }, + }); + let descriptorReads = 0; + const descriptorFailure = new Proxy(descriptorTarget, { + getOwnPropertyDescriptor(target, key) { + descriptorReads += 1; + if (key === 'errors') throw new Error('descriptor trap failed'); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); 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); + for (const candidate of [sameRealm, subclass, foreignRealm, duplicateModuleLike]) { + const snapshot = extractGameSessionOpenFaultErrors(candidate); + assert.notEqual(snapshot, null); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(snapshot?.[0], primary); + assert.equal(snapshot?.[1], cleanup); + assert.equal(isGameSessionOpenFault(candidate), true); + } + const hostileSnapshot = extractGameSessionOpenFaultErrors(hostileIteratorFault); + assert.notEqual(hostileSnapshot, null); + assert.equal(hostileSnapshot?.[0], primary); + assert.equal(hostileSnapshot?.[1], cleanup); + assert.equal(Object.isFrozen(hostileSnapshot), true); + assert.equal(iteratorReads, 0); + assert.equal(hostileErrors[0], primary); + + for (const candidate of [ + ordinaryAggregate, + unbranded, + accessorSpoof, + brandAccessorSpoof, + ]) { + assert.equal(extractGameSessionOpenFaultErrors(candidate), null); + assert.equal(isGameSessionOpenFault(candidate), false); + } + assert.equal(extractGameSessionOpenFaultErrors(descriptorFailure), null); + assert.equal(descriptorReads, 2); assert.equal(accessorReads, 0); });