fix: preserve local host scheduler and fatal errors

This commit is contained in:
2026-09-05 18:36:52 +08:00
parent 745b0a1aeb
commit fff4848654
2 changed files with 52 additions and 4 deletions
@@ -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,
@@ -29,6 +29,7 @@ class Socket {
push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); }
}
async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>; 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<ResolveRuntimeConfigOptions>;
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);
});