From 4307772f2ab810876d1c0f820e6cdf1bae9b6a06 Mon Sep 17 00:00:00 2001 From: shar <66748576+Nat3z@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:29:49 +0000 Subject: [PATCH 1/3] fix(application): recover addon runtime during launch --- application/src/frontend/lib/core/addons.ts | 24 +++- application/src/frontend/lib/core/ipc.ts | 59 +++++---- .../tests/addon-client-reconnect.test.ts | 118 ++++++++++++++++-- 3 files changed, 166 insertions(+), 35 deletions(-) diff --git a/application/src/frontend/lib/core/addons.ts b/application/src/frontend/lib/core/addons.ts index 1ca207f5..ae801776 100644 --- a/application/src/frontend/lib/core/addons.ts +++ b/application/src/frontend/lib/core/addons.ts @@ -38,7 +38,7 @@ export function getAddonIfEventAvailable( ); } -export function runLaunchAppAddons( +function runLaunchAppAddonsOnce( libraryInfo: LibraryInfo, launchType: 'pre' | 'post' ) { @@ -67,6 +67,28 @@ export function runLaunchAppAddons( }); } +export function runLaunchAppAddons( + libraryInfo: LibraryInfo, + launchType: 'pre' | 'post' +) { + return runLaunchAppAddonsOnce(libraryInfo, launchType).pipe( + Effect.catchTag('AddonError', () => + Effect.gen(function* () { + yield* electronRpc.restartAddonServer(); + yield* reconnectClientSdk(); + return yield* runLaunchAppAddonsOnce(libraryInfo, launchType); + }).pipe( + Effect.mapError( + (cause) => + new AddonError({ + message: `Failed to recover the addon runtime: ${formatError(cause)}`, + }) + ) + ) + ) + ); +} + export function findAddonsSupportingStorefront( storefront: string, event: OGIAddonSDKEventListener diff --git a/application/src/frontend/lib/core/ipc.ts b/application/src/frontend/lib/core/ipc.ts index d5fa3e0f..e2ddfb14 100644 --- a/application/src/frontend/lib/core/ipc.ts +++ b/application/src/frontend/lib/core/ipc.ts @@ -71,37 +71,43 @@ export let addonServer = await runFrontendEffect( ) ); -// Keep requests off the closed client while a shared reconnect swaps it out. +// Share reconnects so stale-socket recovery and explicit restarts never race. let reconnectInFlight: Effect.Effect | null = null; +function requestConnectedAddons() { + return Effect.tryPromise({ + try: () => + addonServer.request('query-connected-addons', { + type: 'addons', + }), + catch: (cause) => + new NetworkError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe( + Effect.flatMap((response) => + response.statusError + ? Effect.fail(new AddonError({ message: response.statusError })) + : Effect.succeed(response.args.addons as T[]) + ) + ); +} + export function queryConnectedAddons() { return Effect.suspend(() => (reconnectInFlight ?? Effect.void).pipe( - Effect.mapError( - (cause) => - new AddonError({ - message: `Failed to query connected addons: ${cause.message}`, - }) + Effect.zipRight(requestConnectedAddons()), + Effect.catchTag('NetworkError', () => + reconnectClientSdk().pipe(Effect.zipRight(requestConnectedAddons())) ), - Effect.zipRight( - Effect.tryPromise({ - try: () => - addonServer.request('query-connected-addons', { - type: 'addons', - }), - catch: (cause) => - new AddonError({ - message: `Failed to query connected addons: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }) + Effect.mapError((cause) => + cause._tag === 'AddonError' + ? cause + : new AddonError({ + message: `Failed to query connected addons: ${cause.message}`, + }) ) ) - ).pipe( - Effect.flatMap((response) => - response.statusError - ? Effect.fail(new AddonError({ message: response.statusError })) - : Effect.succeed(response.args.addons as T[]) - ) ); } @@ -117,7 +123,12 @@ export function reconnectClientSdk(): Effect.Effect { message: `Failed to close the addon server connection: ${cause instanceof Error ? cause.message : String(cause)}`, }), }); - addonServer = yield* connectClientSdk(); + // A stale query can detect the backend between its stop and start phases. + addonServer = yield* connectClientSdk().pipe( + Effect.retry( + Schedule.intersect(Schedule.spaced('250 millis'), Schedule.recurs(4)) + ) + ); }); const sharedReconnect = runFrontendSync(Effect.cached(reconnect)).pipe( Effect.ensuring( diff --git a/application/tests/addon-client-reconnect.test.ts b/application/tests/addon-client-reconnect.test.ts index b9047bcf..e747f326 100644 --- a/application/tests/addon-client-reconnect.test.ts +++ b/application/tests/addon-client-reconnect.test.ts @@ -1,4 +1,5 @@ -import { beforeAll, describe, expect, mock, test } from 'bun:test'; +import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { LibraryInfo, OGIAddonSDKEventListener } from '@ogi-sdk/connect'; import { Effect } from 'effect'; mock.module('@/frontend/lib/config/client', () => ({ @@ -6,6 +7,8 @@ mock.module('@/frontend/lib/config/client', () => ({ })); let installedAddonUrls: string[] = []; +let restartAddonServerCalls = 0; +let onRestartAddonServer = () => {}; mock.module('@/frontend/lib/electron-rpc', () => ({ electronRpc: { @@ -14,19 +17,34 @@ mock.module('@/frontend/lib/electron-rpc', () => ({ installedAddonUrls = addons; return addons; }), + restartAddonServer: () => + Effect.sync(() => { + restartAddonServerCalls++; + onRestartAddonServer(); + }), }, })); type MockResponse = { statusError?: string; - args: { addons: { id: string }[] }; + args: { addons: MockAddon[] }; +}; + +type MockAddon = { + id: string; + name?: string; + eventsAvailable?: OGIAddonSDKEventListener[]; }; class MockConnection { private open = true; + public readonly launchCalls: Array<{ + addonId: string; + launchType: 'pre' | 'post'; + }> = []; public constructor( - private readonly addons: { id: string }[], + private readonly addons: MockAddon[], private readonly onClose?: () => Promise ) {} @@ -41,6 +59,22 @@ class MockConnection { return { args: { addons: this.addons } }; } + public addon(addonId: string) { + return { + launchApp: async ({ + launchType, + }: { + libraryInfo: LibraryInfo; + launchType: 'pre' | 'post'; + }) => { + if (!this.open) { + throw new Error('Websocket is not open (readyState: 3)'); + } + this.launchCalls.push({ addonId, launchType }); + }, + }; + } + public async close(): Promise { this.open = false; await this.onClose?.(); @@ -49,16 +83,26 @@ class MockConnection { let closeStarted: Promise; let releaseClose: () => void; -let connections: MockConnection[]; +let connections: Array; +let connectFailuresRemaining = 0; mock.module('@ogi-sdk/client-kit', () => ({ Connection: { - make: () => Promise.resolve(connections.shift()!), + make: () => { + if (connectFailuresRemaining > 0) { + connectFailuresRemaining--; + return Promise.reject(new Error('connect ECONNREFUSED 127.0.0.1:7654')); + } + const connection = connections.shift(); + return connection instanceof Error + ? Promise.reject(connection) + : Promise.resolve(connection!); + }, }, })); let ipc: typeof import('../src/frontend/lib/core/ipc.js'); -let installAddonsAndReconnect: typeof import('../src/frontend/lib/core/addons.js').installAddonsAndReconnect; +let addons: typeof import('../src/frontend/lib/core/addons.js'); beforeAll(async () => { let markCloseStarted: () => void; @@ -76,9 +120,13 @@ beforeAll(async () => { new MockConnection([{ id: 'new' }]), ]; ipc = await import('../src/frontend/lib/core/ipc.js'); - ({ installAddonsAndReconnect } = await import( - '../src/frontend/lib/core/addons.js' - )); + addons = await import('../src/frontend/lib/core/addons.js'); +}); + +beforeEach(() => { + restartAddonServerCalls = 0; + onRestartAddonServer = () => {}; + connectFailuresRemaining = 0; }); describe('addon client reconnect', () => { @@ -97,10 +145,60 @@ describe('addon client reconnect', () => { connections = [new MockConnection([{ id: 'installed' }])]; const connectedAddons = await Effect.runPromise( - installAddonsAndReconnect(['https://example.com/installed']) + addons.installAddonsAndReconnect(['https://example.com/installed']) ); expect(installedAddonUrls).toEqual(['https://example.com/installed']); expect(connectedAddons).toEqual([{ id: 'installed' }]); }); + + test('play hooks recover from a stale addon connection', async () => { + const reconnected = new MockConnection([ + { + id: 'launch-addon', + name: 'Launch Addon', + eventsAvailable: ['launch-app'], + }, + ]); + connections = [ + new Error('connect ECONNREFUSED 127.0.0.1:7654'), + reconnected, + ]; + await ipc.addonServer.close(); + + const result = await Effect.runPromise( + addons.runLaunchAppAddons({ appID: 1 } as LibraryInfo, 'pre') + ); + + expect(result).toEqual({ success: true }); + expect(reconnected.launchCalls).toEqual([ + { addonId: 'launch-addon', launchType: 'pre' }, + ]); + }); + + test('play hooks restart an unhealthy addon runtime', async () => { + const restarted = new MockConnection([ + { + id: 'launch-addon', + name: 'Launch Addon', + eventsAvailable: ['launch-app'], + }, + ]); + connections = [restarted]; + connectFailuresRemaining = Number.POSITIVE_INFINITY; + onRestartAddonServer = () => { + connectFailuresRemaining = 0; + }; + await ipc.addonServer.close(); + + const result = await Effect.runPromise( + addons.runLaunchAppAddons({ appID: 1 } as LibraryInfo, 'pre') + ); + + expect(result).toEqual({ success: true }); + expect(restartAddonServerCalls).toBe(1); + expect(restarted.launchCalls).toEqual([ + { addonId: 'launch-addon', launchType: 'pre' }, + ]); + }); }); From 44fd3c7946e1ab04adc6f92977de3bc155b9ddb0 Mon Sep 17 00:00:00 2001 From: Nat3z <66748576+Nat3z@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:58 -0700 Subject: [PATCH 2/3] fix(addons): enforce startup handshake --- .../src/electron/handlers/handler.addon.ts | 30 ++-- .../electron/lib/renderer-event-readiness.ts | 36 +++++ application/src/electron/main.ts | 59 +++----- .../manager/manager.addon-readiness.ts | 16 +-- application/src/electron/preload.mts | 13 +- application/src/frontend/App.svelte | 31 +---- .../src/frontend/components/PlayPage.svelte | 3 +- .../src/frontend/components/StorePage.svelte | 3 +- .../components/built/UpdateAppModal.svelte | 3 +- application/src/frontend/lib/config/client.ts | 32 ++++- application/src/frontend/lib/core/addons.ts | 18 +-- application/src/frontend/lib/core/ipc.ts | 94 ++++++++----- .../lib/downloads/services/RequestService.ts | 3 +- application/src/frontend/lib/setup/setup.ts | 25 ++-- .../src/frontend/lib/tasks/deferred.ts | 19 +-- application/src/frontend/lib/tasks/runner.ts | 3 +- .../frontend/managers/AppUpdateManager.svelte | 11 +- .../frontend/views/ClientOptionsView.svelte | 22 +-- .../frontend/views/CommunityAddonsList.svelte | 39 +----- .../src/frontend/views/ConfigView.svelte | 5 +- .../src/frontend/views/DiscoverView.svelte | 7 +- .../frontend/views/FocusedAddonView.svelte | 9 +- application/src/lib/electron-rpc.ts | 1 + .../tests/addon-client-reconnect.test.ts | 128 ++++++++++++++++-- .../tests/addon-client-startup.test.ts | 24 ++++ .../addon-configuration-handshake.test.ts | 70 ++++++++++ .../tests/renderer-event-readiness.test.ts | 33 +++++ packages/ogi-addon/src/main.ts | 8 +- 28 files changed, 491 insertions(+), 254 deletions(-) create mode 100644 application/src/electron/lib/renderer-event-readiness.ts create mode 100644 application/tests/addon-client-startup.test.ts create mode 100644 application/tests/addon-configuration-handshake.test.ts create mode 100644 application/tests/renderer-event-readiness.test.ts diff --git a/application/src/electron/handlers/handler.addon.ts b/application/src/electron/handlers/handler.addon.ts index 45802ecb..faa796a7 100644 --- a/application/src/electron/handlers/handler.addon.ts +++ b/application/src/electron/handlers/handler.addon.ts @@ -15,7 +15,7 @@ import { import { AddonMarketplace } from '@/electron/lib/marketplace.js'; import { sendIPCMessage, sendNotification } from '@/electron/main.js'; import { Addon } from '@/electron/manager/manager.addon.js'; -import { waitForAddonsConfigured } from '@/electron/manager/manager.addon-readiness.js'; +import { waitForAddonManifests } from '@/electron/manager/manager.addon-readiness.js'; import { __dirname } from '@/electron/manager/manager.paths.js'; import { ipcProcedure, router } from '@/electron/rpc/router-core.js'; import { deleteInstalledAddon } from '@/electron/server/addon-lifecycle.js'; @@ -110,6 +110,10 @@ export function startAddons(): Effect.Effect { return; } + const runningAddon = Addon.running.get(addonPath); + if (runningAddon?.getChildProcess()) return; + if (runningAddon) Addon.running.delete(addonPath); + logger.sync.info(`Starting addon ${addonPath}`); const instance = yield* Addon.load(addonPath).pipe( Effect.catchAll(() => Effect.succeed(null)) @@ -186,18 +190,9 @@ export function restartAddonServer(): Effect.Effect { logger.sync.info(`Addon Server is running on http://localhost:${port}`); logger.sync.info(`Server is being executed by electron!`); yield* startAddons(); - const configuredAddons = yield* waitForAddonsConfigured(); - for (const connection of configuredAddons) { - yield* Effect.tryPromise({ - try: () => sendIPCMessage('addon-connected', connection.addonInfo!.id), - catch: (cause) => - new AddonError({ - message: `Failed to notify renderer: ${String(cause)}`, - }), - }); - } + yield* waitForAddonManifests(); yield* Effect.tryPromise({ - try: () => sendIPCMessage('addon-runtime-ready'), + try: () => sendIPCMessage('addon-manifests-ready'), catch: (cause) => new AddonError({ message: `Failed to notify renderer: ${String(cause)}`, @@ -624,6 +619,16 @@ export default function AddonManagerHandler(mainWindow: BrowserWindow) { ipcBoundary(() => restartAddonServer()) ); + const ensureAddonsSpawnedProcedure = ipcProcedure( + ElectronRpc.ensureAddonsSpawned, + ipcBoundary(() => + startAddons().pipe( + Effect.zipRight(waitForAddonManifests()), + Effect.asVoid + ) + ) + ); + const deleteInstalledAddonProcedure = ipcProcedure( ElectronRpc.deleteInstalledAddon, ipcBoundary((_, addonID: string) => @@ -1034,6 +1039,7 @@ export default function AddonManagerHandler(mainWindow: BrowserWindow) { return router( installAddons, + ensureAddonsSpawnedProcedure, restartAddonServerProcedure, deleteInstalledAddonProcedure, cleanAddons, diff --git a/application/src/electron/lib/renderer-event-readiness.ts b/application/src/electron/lib/renderer-event-readiness.ts new file mode 100644 index 00000000..46388eea --- /dev/null +++ b/application/src/electron/lib/renderer-event-readiness.ts @@ -0,0 +1,36 @@ +export class RendererEventReadiness { + private ready = false; + private readonly waiters = new Set<() => void>(); + + public isReady(): boolean { + return this.ready; + } + + public markReady(): void { + this.ready = true; + for (const waiter of this.waiters) waiter(); + this.waiters.clear(); + } + + public reset(): void { + this.ready = false; + } + + public wait(timeoutMs: number, onTimeout: () => void): Promise { + if (this.ready) return Promise.resolve(); + + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timeout); + this.waiters.delete(finish); + resolve(); + }; + const timeout = setTimeout(() => { + this.waiters.delete(finish); + onTimeout(); + resolve(); + }, timeoutMs); + this.waiters.add(finish); + }); + } +} diff --git a/application/src/electron/main.ts b/application/src/electron/main.ts index 6e12b5fe..0e7253f1 100644 --- a/application/src/electron/main.ts +++ b/application/src/electron/main.ts @@ -13,6 +13,7 @@ import { } from '@/electron/handlers/handler.library.js'; import { loadLibraryInfo } from '@/electron/handlers/helpers.app/library.js'; import { releasePowerSaveBlock } from '@/electron/lib/power-save.js'; +import { RendererEventReadiness } from '@/electron/lib/renderer-event-readiness.js'; import { createSingleInstanceData, type LaunchForwardPayload, @@ -22,7 +23,7 @@ import { parseWrapperAfterSeparator, } from '@/electron/lib/single-instance-launch.js'; import { Addon } from '@/electron/manager/manager.addon.js'; -import { waitForAddonsConfigured } from '@/electron/manager/manager.addon-readiness.js'; +import { waitForAddonManifests } from '@/electron/manager/manager.addon-readiness.js'; import { __dirname, isDev } from '@/electron/manager/manager.paths.js'; import { stopClient } from '@/electron/manager/manager.webtorrent.js'; import { createElectronRouter } from '@/electron/rpc/router.js'; @@ -195,10 +196,8 @@ export function sendNotification(notification: Notification) { sendIPCMessage('notification', notification); } -let isReadyForEvents = false; - -let readyForEventWaiters: (() => void)[] = []; let clientReadyListenerRegistered = false; +const rendererEventReadiness = new RendererEventReadiness(); const IPC_READY_TIMEOUT_MS = 15000; @@ -208,28 +207,14 @@ export async function sendIPCMessage(channel: string, ...args: any[]) { return; } - if (!isReadyForEvents) { - let resolverRef: (() => void) | null = null; - await Promise.race([ - new Promise((resolve) => { - logger.sync.info('waiting for events'); - resolverRef = resolve; - readyForEventWaiters.push(resolve); - }), - new Promise((resolve) => { - setTimeout(() => { - if (resolverRef !== null) { - const idx = readyForEventWaiters.indexOf(resolverRef); - if (idx !== -1) readyForEventWaiters.splice(idx, 1); - } - logger.sync.warn( - '[sendIPCMessage] client-ready-for-events not received within timeout, proceeding' - ); - resolve(); - }, IPC_READY_TIMEOUT_MS); - }), - ]); - if (isReadyForEvents) logger.sync.info('events ready'); + if (!rendererEventReadiness.isReady()) { + logger.sync.info('waiting for events'); + await rendererEventReadiness.wait(IPC_READY_TIMEOUT_MS, () => + logger.sync.warn( + '[sendIPCMessage] client-ready-for-events not received within timeout, proceeding' + ) + ); + if (rendererEventReadiness.isReady()) logger.sync.info('events ready'); } mainWindow?.webContents.send(channel, ...args); } @@ -288,12 +273,8 @@ function registerClientReadyListener() { if (clientReadyListenerRegistered) return; clientReadyListenerRegistered = true; - ipcMain.on('client-ready-for-events', async () => { - isReadyForEvents = true; - for (const waiter of readyForEventWaiters) { - waiter(); - } - readyForEventWaiters = []; + ipcMain.on('client-ready-for-events', () => { + rendererEventReadiness.markReady(); }); } @@ -336,11 +317,8 @@ async function onMainAppReady() { await runElectronEffect(checkForAddonUpdates(mainWindow)); } await sendIPCMessage('all-addons-started'); - const configuredAddons = await runElectronEffect(waitForAddonsConfigured()); - for (const connection of configuredAddons) { - await sendIPCMessage('addon-connected', connection.addonInfo!.id); - } - await sendIPCMessage('addon-runtime-ready'); + await runElectronEffect(waitForAddonManifests()); + await sendIPCMessage('addon-manifests-ready'); // Register process-wide listeners only once if (!listenersRegistered) { @@ -433,6 +411,13 @@ function createWindow(options: { gameLaunchMode?: boolean } = {}) { } }); + mainWindow.webContents.on( + 'did-start-navigation', + (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame) rendererEventReadiness.reset(); + } + ); + if (!isDev() && !ogiDebug()) mainWindow.removeMenu(); app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling'); diff --git a/application/src/electron/manager/manager.addon-readiness.ts b/application/src/electron/manager/manager.addon-readiness.ts index 72f54ad2..bf0da467 100644 --- a/application/src/electron/manager/manager.addon-readiness.ts +++ b/application/src/electron/manager/manager.addon-readiness.ts @@ -10,18 +10,18 @@ function addonFolderName(addonPath: string): string { return addonPath.replace(/\/$/, '').split(/[/\\]/).pop() ?? addonPath; } -function configuredRunningConnections(): AddonConnection[] { - const configured: AddonConnection[] = []; +function manifestReadyConnections(): AddonConnection[] { + const ready: AddonConnection[] = []; for (const addonPath of Addon.running.keys()) { const client = addonServer.getClient(addonFolderName(addonPath)); if (client?.addonInfo && client.configTemplate !== undefined) { - configured.push(client); + ready.push(client); } } - return configured; + return ready; } -export function waitForAddonsConfigured( +export function waitForAddonManifests( options: { timeoutMs?: number; pollIntervalMs?: number } = {} ): Effect.Effect { return Effect.gen(function* () { @@ -35,16 +35,16 @@ export function waitForAddonsConfigured( const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const ready = configuredRunningConnections(); + const ready = manifestReadyConnections(); if (ready.length >= expectedCount) { return ready; } yield* Effect.sleep(`${pollIntervalMs} millis`); } - const ready = configuredRunningConnections(); + const ready = manifestReadyConnections(); logger.sync.warn( - `[addon-readiness] Timed out waiting for addons to send configure (${ready.length}/${expectedCount} ready)` + `[addon-readiness] Timed out waiting for addon manifests (${ready.length}/${expectedCount} ready)` ); return ready; }); diff --git a/application/src/electron/preload.mts b/application/src/electron/preload.mts index 8da1b1b7..4295aa43 100644 --- a/application/src/electron/preload.mts +++ b/application/src/electron/preload.mts @@ -271,13 +271,6 @@ ipcRenderer.on( }) ); -ipcRenderer.on( - 'addon-connected', - wrap((_, arg) => { - document.dispatchEvent(new CustomEvent('addon-connected', { detail: arg })); - }) -); - ipcRenderer.on( 'migration:event', wrap((_, arg) => { @@ -314,10 +307,10 @@ ipcRenderer.on( ); ipcRenderer.on( - 'addon-runtime-ready', + 'addon-manifests-ready', wrap(() => { - logger.sync.info('ADDON RUNTIME READY'); - document.dispatchEvent(new CustomEvent('addon-runtime-ready')); + logger.sync.info('ADDON MANIFESTS READY'); + document.dispatchEvent(new CustomEvent('addon-manifests-ready')); }) ); diff --git a/application/src/frontend/App.svelte b/application/src/frontend/App.svelte index b83d6301..fb2c35f1 100644 --- a/application/src/frontend/App.svelte +++ b/application/src/frontend/App.svelte @@ -47,14 +47,13 @@ import { viewOpenedWhenChanged, } from '@/frontend/store.svelte'; import { - addonServer, fetchAddonsWithConfigure, + getAddonServerPromise, getConfigClientOption, initDownloadPersistence, initSleepLock, isAddonEventAvailable, queryConnectedAddons, - reconnectClientSdk, } from '@/frontend/utils'; import ClientOptionsView from '@/frontend/views/ClientOptionsView.svelte'; import ConfigView from '@/frontend/views/ConfigView.svelte'; @@ -232,6 +231,7 @@ async function performSearch(query: string) { emptyAddons = new Set(); // Search through addons and organize results by addon + const addonServer = await getAddonServerPromise(); let promises: Promise[] = []; for (const addon of searchAddons) { promises.push( @@ -419,25 +419,6 @@ document.addEventListener('all-addons-started', async () => { type: 'success', }); addonUpdates.set([]); - // restart the addon server - await runFrontendEffect(electronRpc.restartAddonServer()); - await runFrontendEffect( - reconnectClientSdk().pipe( - Effect.catchAll((error) => - Effect.sync(() => { - logger.sync.error( - 'Failed to reconnect to the addon server:', - error - ); - createNotification({ - id: Math.random().toString(36).substring(7), - message: 'Failed to reconnect to the addon server', - type: 'error', - }); - }) - ) - ) - ); } }); document.addEventListener('addon:updated', (event) => { @@ -449,14 +430,6 @@ document.addEventListener('addon:updated', (event) => { }); } }); -document.addEventListener('addon-connected', (event) => { - if (event instanceof CustomEvent) { - runDetached( - fetchAddonsWithConfigure().pipe(Effect.asVoid), - 'Failed to refresh addons' - ); - } -}); currentStorePageOpened.subscribe((value) => { if (value) { heldPageOpened = value; diff --git a/application/src/frontend/components/PlayPage.svelte b/application/src/frontend/components/PlayPage.svelte index 401695e6..e271a94b 100644 --- a/application/src/frontend/components/PlayPage.svelte +++ b/application/src/frontend/components/PlayPage.svelte @@ -35,8 +35,8 @@ import { setHeaderBackButton, } from '@/frontend/store.svelte'; import { - addonServer, fetchAddonsWithConfigure, + getAddonServerPromise, isAddonEventAvailable, runLaunchAppAddons, runTask, @@ -408,6 +408,7 @@ onMount(async () => { ); if (addonsWithStorefront.length === 0) return; + const addonServer = await getAddonServerPromise(); for (const addon of addonsWithStorefront) { searchingAddons[addon.id] = undefined; ( diff --git a/application/src/frontend/components/StorePage.svelte b/application/src/frontend/components/StorePage.svelte index ba5b3733..76eb4d7e 100644 --- a/application/src/frontend/components/StorePage.svelte +++ b/application/src/frontend/components/StorePage.svelte @@ -22,9 +22,9 @@ import { viewOpenedWhenChanged, } from '@/frontend/store.svelte'; import { - addonServer, fetchAddonsWithConfigure, findAddonsSupportingStorefront, + getAddonServerPromise, isAddonEventAvailable, runTask, type SearchResultWithAddon, @@ -215,6 +215,7 @@ async function loadCustomStoreData() { const detailAddons = await runFrontendEffect( findAddonsSupportingStorefront(storefront, 'game-details') ); + const addonServer = await getAddonServerPromise(); let response: StoreData | undefined; for (const addon of detailAddons) { try { diff --git a/application/src/frontend/components/built/UpdateAppModal.svelte b/application/src/frontend/components/built/UpdateAppModal.svelte index 814bae6b..a47c69b7 100644 --- a/application/src/frontend/components/built/UpdateAppModal.svelte +++ b/application/src/frontend/components/built/UpdateAppModal.svelte @@ -12,9 +12,9 @@ import { runFrontendEffect } from '@/frontend/lib/core/runtime'; import { electronRpc } from '@/frontend/lib/electron-rpc'; import { createNotification } from '@/frontend/store.svelte'; import { - addonServer, fetchAddonsWithConfigure, findAddonsSupportingStorefront, + getAddonServerPromise, isAddonEventAvailable, type SearchResultWithAddon, startDownloadEffect, @@ -113,6 +113,7 @@ async function loadUpdateSources() { const detailAddons = await runFrontendEffect( findAddonsSupportingStorefront(storefront, 'game-details') ); + const addonServer = await getAddonServerPromise(); let response: StoreData | undefined; for (const addon of detailAddons) { try { diff --git a/application/src/frontend/lib/config/client.ts b/application/src/frontend/lib/config/client.ts index 56ca4205..74afd4e1 100644 --- a/application/src/frontend/lib/config/client.ts +++ b/application/src/frontend/lib/config/client.ts @@ -12,9 +12,10 @@ import { } from 'ogi-addon/config'; import { type AddonInfo, - addonServer, + getAddonServer, queryConnectedAddons, } from '@/frontend/lib/core/ipc'; +import { runFrontendSync } from '@/frontend/lib/core/runtime'; const logger = createLogger(LOGGER_PREFIXES.frontend); @@ -99,7 +100,7 @@ function waitForConfiguredAddons(maxWaitMs = 15_000, pollMs = 100) { }); } -export function fetchAddonsWithConfigure() { +function configureConnectedAddons() { return Effect.gen(function* () { const addons = yield* waitForConfiguredAddons(); const results = yield* Effect.forEach( @@ -143,6 +144,7 @@ export function fetchAddonsWithConfigure() { ); } + const addonServer = yield* getAddonServer(); yield* Effect.tryPromise({ try: () => addonServer @@ -162,6 +164,30 @@ export function fetchAddonsWithConfigure() { logger.sync.error('Failed to configure addon:', result.left); } } - return addons; + return yield* queryConnectedAddons(); + }); +} + +let configurationInFlight: ReturnType | null = + null; + +// Configuration is the addon runtime handshake: its first config-update emits connect. +export function fetchAddonsWithConfigure() { + return Effect.suspend(() => { + if (configurationInFlight) return configurationInFlight; + + const sharedConfiguration = runFrontendSync( + Effect.cached(configureConnectedAddons()) + ).pipe( + Effect.ensuring( + Effect.sync(() => { + if (configurationInFlight === sharedConfiguration) { + configurationInFlight = null; + } + }) + ) + ); + configurationInFlight = sharedConfiguration; + return sharedConfiguration; }); } diff --git a/application/src/frontend/lib/core/addons.ts b/application/src/frontend/lib/core/addons.ts index ae801776..4264f30a 100644 --- a/application/src/frontend/lib/core/addons.ts +++ b/application/src/frontend/lib/core/addons.ts @@ -1,20 +1,15 @@ import type { LibraryInfo, OGIAddonSDKEventListener } from '@ogi-sdk/connect'; import { AddonError, formatError } from '@ogi-sdk/errors'; import { Effect } from 'effect'; +import { fetchAddonsWithConfigure } from '@/frontend/lib/config/client'; import { electronRpc } from '@/frontend/lib/electron-rpc'; import { supportsStorefront } from '@/lib/storefronts'; -import { - type AddonInfo, - addonServer, - queryConnectedAddons, - reconnectClientSdk, -} from './ipc'; +import { type AddonInfo, getAddonServer, queryConnectedAddons } from './ipc'; -export function installAddonsAndReconnect(addons: string[]) { +export function installAddonsAndReconnect(addons: string[]) { return Effect.gen(function* () { yield* electronRpc.installAddons(addons); - yield* reconnectClientSdk(); - return yield* queryConnectedAddons(); + return yield* fetchAddonsWithConfigure(); }); } @@ -43,9 +38,11 @@ function runLaunchAppAddonsOnce( launchType: 'pre' | 'post' ) { return Effect.gen(function* () { - const addons = (yield* queryConnectedAddons()).filter((addon) => + yield* electronRpc.ensureAddonsSpawned(); + const addons = (yield* fetchAddonsWithConfigure()).filter((addon) => isAddonEventAvailable(addon, 'launch-app') ); + const addonServer = yield* getAddonServer(); const results = yield* Effect.forEach( addons, (addon) => @@ -75,7 +72,6 @@ export function runLaunchAppAddons( Effect.catchTag('AddonError', () => Effect.gen(function* () { yield* electronRpc.restartAddonServer(); - yield* reconnectClientSdk(); return yield* runLaunchAppAddonsOnce(libraryInfo, launchType); }).pipe( Effect.mapError( diff --git a/application/src/frontend/lib/core/ipc.ts b/application/src/frontend/lib/core/ipc.ts index e2ddfb14..629599f1 100644 --- a/application/src/frontend/lib/core/ipc.ts +++ b/application/src/frontend/lib/core/ipc.ts @@ -63,34 +63,62 @@ export function connectClientSdk() { }).pipe(Effect.tap(initialize)); } -// The addon server may still be starting when the renderer loads. -export let addonServer = await runFrontendEffect( - connectClientSdk().pipe( - Effect.tapError((error) => logger.warn('Waiting for addon server:', error)), - Effect.retry(Schedule.spaced('1 second')) - ) -); +let addonServer: Connection | null = null; +let connectionInFlight: Effect.Effect | null = null; -// Share reconnects so stale-socket recovery and explicit restarts never race. +export function getAddonServer(): Effect.Effect { + return Effect.suspend(() => { + if (addonServer) return Effect.succeed(addonServer); + if (connectionInFlight) return connectionInFlight; + + const connect = connectClientSdk().pipe( + Effect.tapError((error) => + logger.warn('Waiting for addon server:', error) + ), + Effect.retry(Schedule.spaced('1 second')), + Effect.tap((connection) => + Effect.sync(() => { + addonServer = connection; + }) + ) + ); + const sharedConnection = runFrontendSync(Effect.cached(connect)).pipe( + Effect.ensuring( + Effect.sync(() => { + if (connectionInFlight === sharedConnection) + connectionInFlight = null; + }) + ) + ); + connectionInFlight = sharedConnection; + return sharedConnection; + }); +} + +export function getAddonServerPromise(): Promise { + return runFrontendEffect(getAddonServer()); +} + +// Share reconnects so concurrent stale-socket recoveries never race. let reconnectInFlight: Effect.Effect | null = null; function requestConnectedAddons() { - return Effect.tryPromise({ - try: () => - addonServer.request('query-connected-addons', { - type: 'addons', - }), - catch: (cause) => - new NetworkError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }).pipe( - Effect.flatMap((response) => - response.statusError - ? Effect.fail(new AddonError({ message: response.statusError })) - : Effect.succeed(response.args.addons as T[]) - ) - ); + return Effect.gen(function* () { + const connection = yield* getAddonServer(); + const response = yield* Effect.tryPromise({ + try: () => + connection.request('query-connected-addons', { + type: 'addons', + }), + catch: (cause) => + new NetworkError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + return response.statusError + ? yield* Effect.fail(new AddonError({ message: response.statusError })) + : (response.args.addons as T[]); + }); } export function queryConnectedAddons() { @@ -116,13 +144,17 @@ export function reconnectClientSdk(): Effect.Effect { if (reconnectInFlight) return reconnectInFlight; const reconnect = Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => addonServer.close(), - catch: (cause) => - new NetworkError({ - message: `Failed to close the addon server connection: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); + const staleConnection = addonServer; + addonServer = null; + if (staleConnection) { + yield* Effect.tryPromise({ + try: () => staleConnection.close(), + catch: (cause) => + new NetworkError({ + message: `Failed to close the addon server connection: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + } // A stale query can detect the backend between its stop and start phases. addonServer = yield* connectClientSdk().pipe( Effect.retry( diff --git a/application/src/frontend/lib/downloads/services/RequestService.ts b/application/src/frontend/lib/downloads/services/RequestService.ts index 541243a1..2463546f 100644 --- a/application/src/frontend/lib/downloads/services/RequestService.ts +++ b/application/src/frontend/lib/downloads/services/RequestService.ts @@ -3,7 +3,7 @@ import { DownloadError } from '@ogi-sdk/errors'; import { createLogger, LOGGER_PREFIXES } from '@ogi-sdk/logger'; import { Effect } from 'effect'; import { getDownloadPath } from '@/frontend/lib/core/fs'; -import { addonServer } from '@/frontend/lib/core/ipc'; +import { getAddonServer } from '@/frontend/lib/core/ipc'; import { startDownloadEffect } from '@/frontend/lib/downloads/lifecycle'; import { safeDownloadPath } from '@/frontend/lib/downloads/paths'; import { BaseService } from '@/frontend/lib/downloads/services/BaseService'; @@ -51,6 +51,7 @@ export class RequestService extends BaseService { cause, }), }); + const addonServer = yield* getAddonServer(); const response = yield* Effect.tryPromise({ try: () => addonServer diff --git a/application/src/frontend/lib/setup/setup.ts b/application/src/frontend/lib/setup/setup.ts index f0d94fb9..aa231fb6 100644 --- a/application/src/frontend/lib/setup/setup.ts +++ b/application/src/frontend/lib/setup/setup.ts @@ -12,7 +12,7 @@ import { import { createLogger, LOGGER_PREFIXES } from '@ogi-sdk/logger'; import { Effect } from 'effect'; import { get } from 'svelte/store'; -import { addonServer } from '@/frontend/lib/core/ipc'; +import { getAddonServer } from '@/frontend/lib/core/ipc'; import { getApp } from '@/frontend/lib/core/library'; import { updateDownloadStatus } from '@/frontend/lib/downloads/lifecycle'; import { electronRpc } from '@/frontend/lib/electron-rpc'; @@ -163,16 +163,19 @@ function runAddonSetup( callbacks: ReturnType ) { const { addonID, ...setupArgs } = setupPayload; - return Effect.tryPromise({ - try: () => - addonServer - .addon(addonID, callbacks) - .setup(setupArgs) as Promise, - catch: (cause) => - new AddonError({ - message: formatError(cause), - addonName: addonID, - }), + return Effect.gen(function* () { + const addonServer = yield* getAddonServer(); + return yield* Effect.tryPromise({ + try: () => + addonServer + .addon(addonID, callbacks) + .setup(setupArgs) as Promise, + catch: (cause) => + new AddonError({ + message: formatError(cause), + addonName: addonID, + }), + }); }); } diff --git a/application/src/frontend/lib/tasks/deferred.ts b/application/src/frontend/lib/tasks/deferred.ts index cbd9ea77..502abb3c 100644 --- a/application/src/frontend/lib/tasks/deferred.ts +++ b/application/src/frontend/lib/tasks/deferred.ts @@ -2,7 +2,7 @@ import type { DeferredTaskSnapshot } from '@ogi-sdk/client-kit'; import { AddonError, formatError } from '@ogi-sdk/errors'; import { createLogger, LOGGER_PREFIXES } from '@ogi-sdk/logger'; import { Effect } from 'effect'; -import { addonServer } from '@/frontend/lib/core/ipc'; +import { getAddonServer } from '@/frontend/lib/core/ipc'; import { type DeferredTask, deferredTasks, @@ -12,13 +12,16 @@ import { const logger = createLogger(LOGGER_PREFIXES.frontend); export function loadDeferredTasks(tasksToRemove: string[] = []) { - return Effect.tryPromise({ - try: () => addonServer.getDeferredTasks(), - catch: (cause) => - new AddonError({ - message: `Failed to load deferred tasks: ${formatError(cause)}`, - }), - }).pipe( + return getAddonServer().pipe( + Effect.flatMap((addonServer) => + Effect.tryPromise({ + try: () => addonServer.getDeferredTasks(), + catch: (cause) => + new AddonError({ + message: `Failed to load deferred tasks: ${formatError(cause)}`, + }), + }) + ), Effect.tap((tasks) => Effect.sync(() => { deferredTasks.set( diff --git a/application/src/frontend/lib/tasks/runner.ts b/application/src/frontend/lib/tasks/runner.ts index 79b107a6..b07ba403 100644 --- a/application/src/frontend/lib/tasks/runner.ts +++ b/application/src/frontend/lib/tasks/runner.ts @@ -2,7 +2,7 @@ import type { LibraryInfo, SearchResult } from '@ogi-sdk/connect'; import { AddonError, formatError } from '@ogi-sdk/errors'; import { Effect, Exit } from 'effect'; import { get } from 'svelte/store'; -import { addonServer } from '@/frontend/lib/core/ipc'; +import { getAddonServer } from '@/frontend/lib/core/ipc'; import { createNotification, deferredTasks } from '@/frontend/store.svelte'; export type SearchResultWithAddon = SearchResult & { @@ -42,6 +42,7 @@ export function runTask( ...(libraryInfo ? { libraryInfo: structuredClone(libraryInfo) } : {}), }; + const addonServer = yield* getAddonServer(); const response = yield* Effect.tryPromise({ try: () => addonServer diff --git a/application/src/frontend/managers/AppUpdateManager.svelte b/application/src/frontend/managers/AppUpdateManager.svelte index e97b36dd..cddee245 100644 --- a/application/src/frontend/managers/AppUpdateManager.svelte +++ b/application/src/frontend/managers/AppUpdateManager.svelte @@ -6,11 +6,10 @@ import core from '@/frontend/lib/core'; import { runFrontendEffect } from '@/frontend/lib/core/runtime'; import { updatesManager } from '@/frontend/states.svelte'; import { - addonServer, fetchAddonsWithConfigure, + getAddonServer, isAddonEventAvailable, queryConnectedAddons, - reconnectClientSdk, } from '@/frontend/utils'; import { supportsStorefront } from '@/lib/storefronts'; @@ -18,14 +17,13 @@ const logger = createLogger(LOGGER_PREFIXES.frontend); let updateCheckRunId = 0; -document.addEventListener('addon-runtime-ready', () => { - void onAddonRuntimeReady(); +document.addEventListener('addon-manifests-ready', () => { + void onAddonManifestsReady(); }); -async function onAddonRuntimeReady() { +async function onAddonManifestsReady() { await runFrontendEffect( Effect.gen(function* () { - yield* reconnectClientSdk(); yield* fetchAddonsWithConfigure(); yield* checkForAppUpdates(); }).pipe( @@ -53,6 +51,7 @@ function checkForAppUpdates() { }), }); const connectedAddons = yield* queryConnectedAddons(); + const addonServer = yield* getAddonServer(); yield* Effect.forEach( library, (app) => diff --git a/application/src/frontend/views/ClientOptionsView.svelte b/application/src/frontend/views/ClientOptionsView.svelte index 651f7d7f..1472e769 100644 --- a/application/src/frontend/views/ClientOptionsView.svelte +++ b/application/src/frontend/views/ClientOptionsView.svelte @@ -16,7 +16,6 @@ import ThemePicker from '@/frontend/components/ThemePicker.svelte'; import { runFrontendEffect } from '@/frontend/lib/core/runtime'; import { electronRpc } from '@/frontend/lib/electron-rpc'; import { createNotification } from '@/frontend/store.svelte'; -import { fetchAddonsWithConfigure, reconnectClientSdk } from '@/frontend/utils'; const logger = createLogger(LOGGER_PREFIXES.frontend); @@ -542,10 +541,7 @@ async function updateAddons() { async function restartAddonServer() { isRestartingServer = true; await runFrontendEffect( - Effect.gen(function* () { - yield* electronRpc.restartAddonServer(); - yield* reconnectClientSdk(); - }).pipe( + electronRpc.restartAddonServer().pipe( Effect.catchAll((error) => logger.error('Failed to restart addon server:', error) ), @@ -704,26 +700,10 @@ onMount(() => { doSteamGridDBReconfigure = true; reasonForSteamGridLaunch = (event as CustomEvent).detail || ''; } - async function handleAddonConnected() { - await runFrontendEffect( - fetchAddonsWithConfigure().pipe( - Effect.catchAll((error) => - logger.error('Failed to configure addons after reconnect:', error) - ), - Effect.ensuring( - Effect.sync(() => { - isRestartingServer = false; - }) - ) - ) - ); - } document.addEventListener('steamgriddb-launch', steamgriddbLaunch); - document.addEventListener('addon-connected', handleAddonConnected); return () => { document.removeEventListener('steamgriddb-launch', steamgriddbLaunch); - document.removeEventListener('addon-connected', handleAddonConnected); }; }); diff --git a/application/src/frontend/views/CommunityAddonsList.svelte b/application/src/frontend/views/CommunityAddonsList.svelte index c61b9b7f..2e8afcd4 100644 --- a/application/src/frontend/views/CommunityAddonsList.svelte +++ b/application/src/frontend/views/CommunityAddonsList.svelte @@ -1,6 +1,5 @@