fix(platform): preserve session failures and URL syntax
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import type { GameHost } from '../sdk/contracts/index.ts';
|
||||
import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
||||
import {
|
||||
GameSessionHost,
|
||||
GameSessionOpenFault,
|
||||
} from '../sdk/runtime/game-session-host.ts';
|
||||
import {
|
||||
parseLoginResponse,
|
||||
parseSelfJoinRoomResponse,
|
||||
@@ -167,7 +170,8 @@ export class RuntimeSession {
|
||||
});
|
||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||
} catch (error) {
|
||||
this.throwFault(operation, [error, ...this.releaseGame()]);
|
||||
const errors = error instanceof GameSessionOpenFault ? [...error.errors] : [error];
|
||||
this.throwFault(operation, [...errors, ...this.releaseGame()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,9 @@ function parseSwitchTarget(address: string, path: string): string {
|
||||
if (authority === undefined || authority.length === 0) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
|
||||
}
|
||||
if (target.includes('#') || authority.includes('@')) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address without fragment or userinfo`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,16 @@ export type GameSessionState =
|
||||
| 'disposing'
|
||||
| 'disposed';
|
||||
|
||||
export class GameSessionOpenFault extends Error {
|
||||
readonly errors: readonly unknown[];
|
||||
|
||||
constructor(primary: unknown, cleanup: unknown) {
|
||||
super('Game session open failed and module cleanup also failed');
|
||||
this.name = 'GameSessionOpenFault';
|
||||
this.errors = Object.freeze([primary, cleanup]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates the compile-time game identity before it reaches the runtime. */
|
||||
export function assertGameEntry(entry: GameEntry): void {
|
||||
assertNonEmptyText(entry.key, 'key');
|
||||
@@ -38,7 +48,7 @@ export function assertGameEntry(entry: GameEntry): void {
|
||||
try {
|
||||
assertGameModule(module);
|
||||
} finally {
|
||||
disposeValidationModule(module);
|
||||
disposeModuleIfPresent(module);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +81,20 @@ export class GameSessionHost {
|
||||
this.module = module;
|
||||
module.attach(host);
|
||||
this.currentState = 'active';
|
||||
} catch (error) {
|
||||
} catch (primaryError) {
|
||||
this.module = undefined;
|
||||
let cleanupFailed = false;
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
module?.dispose();
|
||||
disposeModuleIfPresent(module);
|
||||
} catch (error) {
|
||||
cleanupFailed = true;
|
||||
cleanupError = error;
|
||||
} finally {
|
||||
this.currentState = 'idle';
|
||||
}
|
||||
throw error;
|
||||
if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError);
|
||||
throw primaryError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +190,7 @@ function assertGameModule(module: unknown): asserts module is GameModule {
|
||||
}
|
||||
}
|
||||
|
||||
function disposeValidationModule(module: unknown): void {
|
||||
function disposeModuleIfPresent(module: unknown): void {
|
||||
if (!module || typeof module !== 'object') return;
|
||||
const dispose = (module as Record<string, unknown>).dispose;
|
||||
if (typeof dispose === 'function') dispose.call(module);
|
||||
|
||||
@@ -366,3 +366,20 @@ test('RuntimeSession aggregates event, dispose, and invalidation failures after
|
||||
[setup.errors.event, setup.errors.dispose, setup.errors.invalidate],
|
||||
);
|
||||
});
|
||||
|
||||
test('RuntimeSession flattens attach cleanup failures before appending lease invalidation', () => {
|
||||
const setup = makeLeaseSession();
|
||||
setup.setFailures('attach', 'dispose', 'invalidate');
|
||||
|
||||
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(setup.getModuleDisposals(), 1);
|
||||
assert.deepEqual(
|
||||
(error as Error & { errors?: readonly unknown[] }).errors,
|
||||
[setup.errors.attach, setup.errors.dispose, setup.errors.invalidate],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -512,6 +512,10 @@ test('PlatformHandlers validates every switch URL before closing the active game
|
||||
'http://invalid.example',
|
||||
'ws://user@example.com',
|
||||
'ws://example.com/#fragment',
|
||||
'ws://example.com/#',
|
||||
'ws://example.com#',
|
||||
'ws://@example.com',
|
||||
'ws://:@example.com',
|
||||
'ws:///path',
|
||||
'ws://example.com:99999',
|
||||
'ws://example.com:abc',
|
||||
@@ -544,7 +548,10 @@ test('PlatformHandlers validates every switch URL before closing the active game
|
||||
|
||||
test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => {
|
||||
const setup = makeHandlers();
|
||||
const roomData = { roomserver: 'WS://EXAMPLE.com:80/path?x=1', extra: { trace: 1 } };
|
||||
const roomData = {
|
||||
roomserver: 'WS://EXAMPLE.com:80/path/%23anchor?x=1',
|
||||
extra: { trace: 1 },
|
||||
};
|
||||
const agentData = {
|
||||
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
|
||||
};
|
||||
|
||||
@@ -39,6 +39,16 @@ function makeEntry(createModule: () => GameModule): GameEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function caughtError(operation: () => void): Error {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof Error);
|
||||
return error;
|
||||
}
|
||||
assert.fail('expected operation to throw');
|
||||
}
|
||||
|
||||
function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule {
|
||||
let attached = false;
|
||||
return {
|
||||
@@ -153,6 +163,64 @@ test('GameSessionHost disposes a module when attachment throws and returns to id
|
||||
assert.equal(sessions.state, 'idle');
|
||||
});
|
||||
|
||||
test('GameSessionHost preserves create and contract failures when open cleanup succeeds', () => {
|
||||
const createError = new Error('create failed');
|
||||
let createCalls = 0;
|
||||
const createSessions = new GameSessionHost(makeEntry(() => {
|
||||
createCalls += 1;
|
||||
if (createCalls === 1) return makeModule([]);
|
||||
throw createError;
|
||||
}));
|
||||
|
||||
assert.equal(caughtError(() => createSessions.open(makeHost())), createError);
|
||||
assert.equal(createSessions.state, 'idle');
|
||||
|
||||
let contractCalls = 0;
|
||||
let contractDisposals = 0;
|
||||
const contractSessions = new GameSessionHost(makeEntry(() => {
|
||||
contractCalls += 1;
|
||||
if (contractCalls === 1) return makeModule([]);
|
||||
return {
|
||||
handlePlatformEvent() {},
|
||||
handleGameMessage() {},
|
||||
restore() {},
|
||||
dispose() { contractDisposals += 1; },
|
||||
} as unknown as GameModule;
|
||||
}));
|
||||
|
||||
assert.match(caughtError(() => contractSessions.open(makeHost())).message, /implement attach/);
|
||||
assert.equal(contractDisposals, 1);
|
||||
assert.equal(contractSessions.state, 'idle');
|
||||
});
|
||||
|
||||
test('GameSessionHost preserves attach then dispose failures by identity and occurrence order', () => {
|
||||
const attachError = new Error('attach primary');
|
||||
const disposeError = new Error('open cleanup');
|
||||
let createCalls = 0;
|
||||
let disposals = 0;
|
||||
const sessions = new GameSessionHost(makeEntry(() => {
|
||||
createCalls += 1;
|
||||
if (createCalls === 1) return makeModule([]);
|
||||
return {
|
||||
...makeModule([]),
|
||||
attach() { throw attachError; },
|
||||
dispose() {
|
||||
disposals += 1;
|
||||
throw disposeError;
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
const error = caughtError(() => sessions.open(makeHost()));
|
||||
|
||||
assert.deepEqual(
|
||||
(error as Error & { errors?: readonly unknown[] }).errors,
|
||||
[attachError, disposeError],
|
||||
);
|
||||
assert.equal(disposals, 1);
|
||||
assert.equal(sessions.state, 'idle');
|
||||
});
|
||||
|
||||
test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => {
|
||||
const log: string[] = [];
|
||||
const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));
|
||||
|
||||
Reference in New Issue
Block a user