diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts index a5ac193..a943911 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts @@ -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()]); } } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts index 3f666e6..17de2b1 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts @@ -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 { 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 689acec..ded9da3 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 @@ -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).dispose; if (typeof dispose === 'function') dispose.call(module); diff --git a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts index 468d061..b209c5d 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -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], + ); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts index 3394171..7325b6f 100644 --- a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts @@ -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 }, }; 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 e8d68f6..51afc26 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -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)));