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 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 {
|
import {
|
||||||
parseLoginResponse,
|
parseLoginResponse,
|
||||||
parseSelfJoinRoomResponse,
|
parseSelfJoinRoomResponse,
|
||||||
@@ -167,7 +170,8 @@ 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) {
|
||||||
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) {
|
if (authority === undefined || authority.length === 0) {
|
||||||
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
|
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;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export type GameSessionState =
|
|||||||
| 'disposing'
|
| 'disposing'
|
||||||
| 'disposed';
|
| '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. */
|
/** 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');
|
||||||
@@ -38,7 +48,7 @@ export function assertGameEntry(entry: GameEntry): void {
|
|||||||
try {
|
try {
|
||||||
assertGameModule(module);
|
assertGameModule(module);
|
||||||
} finally {
|
} finally {
|
||||||
disposeValidationModule(module);
|
disposeModuleIfPresent(module);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +81,20 @@ export class GameSessionHost {
|
|||||||
this.module = module;
|
this.module = module;
|
||||||
module.attach(host);
|
module.attach(host);
|
||||||
this.currentState = 'active';
|
this.currentState = 'active';
|
||||||
} catch (error) {
|
} catch (primaryError) {
|
||||||
this.module = undefined;
|
this.module = undefined;
|
||||||
|
let cleanupFailed = false;
|
||||||
|
let cleanupError: unknown;
|
||||||
try {
|
try {
|
||||||
module?.dispose();
|
disposeModuleIfPresent(module);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupFailed = true;
|
||||||
|
cleanupError = error;
|
||||||
} finally {
|
} finally {
|
||||||
this.currentState = 'idle';
|
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;
|
if (!module || typeof module !== 'object') return;
|
||||||
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);
|
||||||
|
|||||||
@@ -366,3 +366,20 @@ test('RuntimeSession aggregates event, dispose, and invalidation failures after
|
|||||||
[setup.errors.event, setup.errors.dispose, setup.errors.invalidate],
|
[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',
|
'http://invalid.example',
|
||||||
'ws://user@example.com',
|
'ws://user@example.com',
|
||||||
'ws://example.com/#fragment',
|
'ws://example.com/#fragment',
|
||||||
|
'ws://example.com/#',
|
||||||
|
'ws://example.com#',
|
||||||
|
'ws://@example.com',
|
||||||
|
'ws://:@example.com',
|
||||||
'ws:///path',
|
'ws:///path',
|
||||||
'ws://example.com:99999',
|
'ws://example.com:99999',
|
||||||
'ws://example.com:abc',
|
'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', () => {
|
test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => {
|
||||||
const setup = makeHandlers();
|
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 = {
|
const agentData = {
|
||||||
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
|
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 {
|
function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule {
|
||||||
let attached = false;
|
let attached = false;
|
||||||
return {
|
return {
|
||||||
@@ -153,6 +163,64 @@ test('GameSessionHost disposes a module when attachment throws and returns to id
|
|||||||
assert.equal(sessions.state, 'idle');
|
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', () => {
|
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