diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts index 00e6418..f7d1cf9 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts @@ -138,10 +138,24 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin if (model.kind === 'denial') serverFailure = { data: model.outcome.data }; if (model.kind === 'kicked') serverFailure = { data: model.data }; try { options.view.showOverlay(model); } - catch (error) { transportFault(error); throw error; } + catch (error) { + if (model.kind === 'fatal') fail(collected([model.error, error])); + else transportFault(error); + throw error; + } if (model.kind === 'fatal') fail(model.error); }, - }, options.scheduler, fail); + }, { + request: callback => { + try { + const cancel = options.scheduler.request(callback); + return () => { + try { cancel(); } + catch (error) { fail(error); throw error; } + }; + } catch (error) { fail(error); throw error; } + }, + }, fail); runtime = new PlatformRuntime({ gameEntry: entry, resolveRuntimeConfig: async () => context.config, createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault, diff --git a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts index 9b244f0..c3fcea9 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts @@ -29,6 +29,7 @@ class Socket { push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } } async function setup(overrides: { config?: Partial; render?: (model: PageModel) => void; + cancelFrame?: () => void; showOverlay?: (model: OverlayModel) => void; record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void } = {}) { const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts') .catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`)); @@ -43,8 +44,8 @@ async function setup(overrides: { config?: Partial; storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } }, random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }), view: { render: model => { overrides.render?.(model); pages.push(model); }, - activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); } }, - scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); }; } }, + activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); overrides.showOverlay?.(model); } }, + scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); overrides.cancelFrame?.(); }; } }, loadResources: () => resources.promise, waitForMinimumDisplay: () => minimum.promise, createSocket: () => { const socket = new Socket(); sockets.push(socket); return socket as unknown as WebSocket; }, record: event => { evidence.push(event); overrides.record?.(event); }, @@ -176,3 +177,36 @@ test('kick data remains primary when closing its socket also fails', async () => const errors = (h.failures[0] as { errors: unknown[] }).errors; assert.deepEqual(errors[0], data); assert.ok(errors.includes(cleanup)); }); + +test('pending frame cancellation retains its primary error when socket cleanup also fails', async () => { + const primary = new Error('pending frame cancel failed'), cleanup = new Error('socket close failed'); + let armed = false; + const h = await setup({ cancelFrame: () => { if (armed) throw primary; } }); + await h.ready(); await h.host.login(); + armed = true; h.sockets[0]!.closeError = cleanup; + // The real login response schedules a Store frame, then showLobby flush cancels it. + h.sockets[0]!.push(fixture()); + assert.equal(h.failures.length, 1); + const fault = h.failures[0] as { errors?: unknown[] }; + assert.ok(fault.errors, 'primary and cleanup failures must both be retained'); + assert.strictEqual(fault.errors[0], primary); + assert.ok(fault.errors.includes(cleanup)); + assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.frames.size, 0); + assert.equal(h.sockets[0]!.closes, 1); +}); + +test('fatal overlay observer failure retains the original Runtime error object', async () => { + const observer = new Error('fatal overlay observer failed'); + let primary: Error | undefined; + const h = await setup({ showOverlay: model => { + if (model.kind === 'fatal') { primary = model.error; throw observer; } + } }); + await h.ready(); + h.sockets[0]!.push({ route: 'agent', rpc: 'unsupported_test_rpc', data: {} }); + assert.ok(primary); assert.match(primary.message, /Unsupported platform rpc/); + assert.equal(h.failures.length, 1); + const fault = h.failures[0] as { errors?: unknown[] }; + assert.ok(fault.errors, 'Runtime error and overlay observer error must both be retained'); + assert.strictEqual(fault.errors[0], primary); assert.strictEqual(fault.errors[1], observer); + assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.sockets[0]!.closes, 1); +});