fix(platform): snapshot session fault errors safely
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 {
|
||||||
|
extractGameSessionOpenFaultErrors,
|
||||||
GameSessionHost,
|
GameSessionHost,
|
||||||
isGameSessionOpenFault,
|
|
||||||
} from '../sdk/runtime/game-session-host.ts';
|
} from '../sdk/runtime/game-session-host.ts';
|
||||||
import {
|
import {
|
||||||
parseLoginResponse,
|
parseLoginResponse,
|
||||||
@@ -170,8 +170,12 @@ 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 = isGameSessionOpenFault(error) ? [...error.errors] : [error];
|
const openErrors = extractGameSessionOpenFaultErrors(error);
|
||||||
this.throwFault(operation, [...errors, ...this.releaseGame()]);
|
const errors = openErrors === null
|
||||||
|
? [error]
|
||||||
|
: [openErrors[0], openErrors[1]];
|
||||||
|
errors.push(...this.releaseGame());
|
||||||
|
this.throwFault(operation, errors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-12
@@ -19,39 +19,64 @@ export type GameSessionState =
|
|||||||
| 'disposing'
|
| 'disposing'
|
||||||
| 'disposed';
|
| 'disposed';
|
||||||
|
|
||||||
|
export type GameSessionOpenFaultErrors = readonly [unknown, unknown];
|
||||||
|
|
||||||
export class GameSessionOpenFault extends Error {
|
export class GameSessionOpenFault extends Error {
|
||||||
readonly errors: readonly unknown[];
|
declare readonly errors: GameSessionOpenFaultErrors;
|
||||||
|
|
||||||
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 });
|
const errors = Object.freeze([primary, cleanup] as [unknown, unknown]);
|
||||||
this.errors = Object.freeze([primary, cleanup]);
|
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 {
|
export interface GameSessionOpenFaultShape {
|
||||||
readonly errors: readonly [unknown, unknown];
|
readonly errors: GameSessionOpenFaultErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recognizes open faults across realms and duplicate module instances. */
|
/** Snapshots open-fault errors across realms and duplicate module instances. */
|
||||||
export function isGameSessionOpenFault(value: unknown): value is GameSessionOpenFaultShape {
|
export function extractGameSessionOpenFaultErrors(
|
||||||
if (value === null || typeof value !== 'object') return false;
|
value: unknown,
|
||||||
|
): GameSessionOpenFaultErrors | null {
|
||||||
|
if (value === null || typeof value !== 'object') return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const brand = ownDataDescriptor(value, GAME_SESSION_OPEN_FAULT_BRAND);
|
const brand = ownDataDescriptor(value, GAME_SESSION_OPEN_FAULT_BRAND);
|
||||||
const errors = ownDataDescriptor(value, 'errors');
|
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;
|
const errorList = errors.value;
|
||||||
return ownDataDescriptor(errorList, 'length')?.value === 2
|
const length = ownDataDescriptor(errorList, 'length');
|
||||||
&& ownDataDescriptor(errorList, '0') !== undefined
|
const primary = ownDataDescriptor(errorList, '0');
|
||||||
&& ownDataDescriptor(errorList, '1') !== undefined;
|
const cleanup = ownDataDescriptor(errorList, '1');
|
||||||
|
if (length?.value !== 2 || !primary || !cleanup) return null;
|
||||||
|
|
||||||
|
return Object.freeze([primary.value, cleanup.value] as [unknown, unknown]);
|
||||||
} catch {
|
} 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. */
|
/** 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');
|
||||||
|
|||||||
@@ -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', () => {
|
test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs as single errors', () => {
|
||||||
const primary = new Error('user primary');
|
const primary = new Error('user primary');
|
||||||
const cleanup = new Error('user cleanup');
|
const cleanup = new Error('user cleanup');
|
||||||
@@ -445,12 +480,26 @@ test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs
|
|||||||
},
|
},
|
||||||
errors: { value: [primary, cleanup] },
|
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 [
|
for (const candidate of [
|
||||||
ordinaryAggregate,
|
ordinaryAggregate,
|
||||||
unbranded,
|
unbranded,
|
||||||
accessorSpoof,
|
accessorSpoof,
|
||||||
brandAccessorSpoof,
|
brandAccessorSpoof,
|
||||||
|
descriptorFailure,
|
||||||
]) {
|
]) {
|
||||||
const setup = makeLeaseSession();
|
const setup = makeLeaseSession();
|
||||||
setup.setFailures('invalidate');
|
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(setup.leases[0]?.invalidations, 1);
|
||||||
}
|
}
|
||||||
assert.equal(accessorReads, 0);
|
assert.equal(accessorReads, 0);
|
||||||
|
assert.equal(descriptorReads, 2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -226,16 +226,65 @@ 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', () => {
|
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 OpenFaultShape = { readonly errors: readonly unknown[] };
|
||||||
type OpenFaultGuard = (value: unknown) => value is OpenFaultShape;
|
type OpenFaultGuard = (value: unknown) => value is OpenFaultShape;
|
||||||
const exported = (gameSessionRuntime as unknown as Record<string, unknown>)
|
type OpenFaultExtractor = (value: unknown) => readonly [unknown, unknown] | null;
|
||||||
.isGameSessionOpenFault;
|
const runtimeExports = gameSessionRuntime as unknown as Record<string, unknown>;
|
||||||
assert.equal(typeof exported, 'function');
|
const guardExport = runtimeExports.isGameSessionOpenFault;
|
||||||
const isGameSessionOpenFault = exported as OpenFaultGuard;
|
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 primary = new Error('primary');
|
||||||
const cleanup = new Error('cleanup');
|
const cleanup = new Error('cleanup');
|
||||||
const sameRealm = new GameSessionOpenFault(primary, cleanup);
|
const sameRealm = new GameSessionOpenFault(primary, cleanup);
|
||||||
|
class OpenFaultSubclass extends GameSessionOpenFault {}
|
||||||
|
const subclass = new OpenFaultSubclass(primary, cleanup);
|
||||||
const foreignRealm = runInNewContext(`(() => {
|
const foreignRealm = runInNewContext(`(() => {
|
||||||
const fault = new Error('foreign open fault');
|
const fault = new Error('foreign open fault');
|
||||||
Object.defineProperties(fault, {
|
Object.defineProperties(fault, {
|
||||||
@@ -244,6 +293,11 @@ test('open fault guard recognizes the global brand across realms without reading
|
|||||||
});
|
});
|
||||||
return fault;
|
return fault;
|
||||||
})()`, { primary, cleanup });
|
})()`, { 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(
|
const ordinaryAggregate = runInNewContext(
|
||||||
'new AggregateError([primary, cleanup], "user aggregate")',
|
'new AggregateError([primary, cleanup], "user aggregate")',
|
||||||
{ primary, cleanup },
|
{ primary, cleanup },
|
||||||
@@ -270,14 +324,64 @@ test('open fault guard recognizes the global brand across realms without reading
|
|||||||
},
|
},
|
||||||
errors: { value: [primary, cleanup] },
|
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(foreignRealm instanceof GameSessionOpenFault, false);
|
||||||
assert.equal(isGameSessionOpenFault(sameRealm), true);
|
for (const candidate of [sameRealm, subclass, foreignRealm, duplicateModuleLike]) {
|
||||||
assert.equal(isGameSessionOpenFault(foreignRealm), true);
|
const snapshot = extractGameSessionOpenFaultErrors(candidate);
|
||||||
assert.equal(isGameSessionOpenFault(ordinaryAggregate), false);
|
assert.notEqual(snapshot, null);
|
||||||
assert.equal(isGameSessionOpenFault(unbranded), false);
|
assert.equal(Object.isFrozen(snapshot), true);
|
||||||
assert.equal(isGameSessionOpenFault(accessorSpoof), false);
|
assert.equal(snapshot?.[0], primary);
|
||||||
assert.equal(isGameSessionOpenFault(brandAccessorSpoof), false);
|
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);
|
assert.equal(accessorReads, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user