From e7c49425435a30e909c7e6564ca9a69ac600ddb6 Mon Sep 17 00:00:00 2001 From: Daniyal <115576848+daniyal-sh@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:57:03 -0500 Subject: [PATCH 1/3] fix(desktop): stop blocking the renderer during topology polling --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/methods/window.ts | 137 ++++++++++--------- apps/desktop/src/preload.ts | 26 ++-- apps/desktop/src/preloadSnapshot.test.ts | 62 +++++++++ apps/desktop/src/preloadSnapshot.ts | 24 ++++ apps/web/src/connection/desktopLocal.test.ts | 41 ++++++ apps/web/src/connection/desktopLocal.ts | 64 +++++---- apps/web/src/connection/platform.ts | 2 +- docs/internals/performance-regressions.md | 5 +- packages/contracts/src/ipc.ts | 2 + 10 files changed, 268 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/src/preloadSnapshot.test.ts create mode 100644 apps/desktop/src/preloadSnapshot.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e8..80058008e46c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { import { getAppBranding, getLocalEnvironmentBootstraps, + readLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getSystemLocale, getWindowFullscreenState, @@ -97,6 +98,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handle(readLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 33895ffffbd1..c19e1c6c483d 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -90,73 +90,84 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const instances = yield* pool.list; - const bootstraps: DesktopEnvironmentBootstrap[] = []; - for (const instance of instances) { - const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; - const config = yield* instance.currentConfig; - const snapshot = yield* instance.snapshot; - // A secondary backend (e.g. a parallel WSL backend) that hasn't produced - // a config yet (mid-registration, before its first start cycle) or that - // is retrying a *transient* preflight failure (WSL VM still booting, a - // not-yet-built linux server entry) is not listening on a port. We - // surface it as a *pending* bootstrap (null endpoints, no token) so the - // renderer can show a "Connecting…" indicator while it retries — null - // endpoints keep the renderer from dialing the dead port, avoiding the - // needless /api/auth/bootstrap/bearer error cycles a real endpoint would - // trigger. - if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { - // Skip the primary (same-origin, no "connecting" affordance) and skip a - // secondary whose preflight failed *fatally* (no node, wrong version, - // missing build tools): it has stopped retrying, so an indefinite - // "Connecting…" would be misleading — its error is surfaced by the - // WSL-state UI instead. - const fatalPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - config.value.preflightFailure.value.fatal; - const stoppedPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - (!snapshot.desiredRunning || !snapshot.restartScheduled); - if (isPrimary || fatalPreflight || stoppedPreflight) continue; - bootstraps.push({ - id: instance.id, - label: yield* instance.label, - runningDistro: null, - httpBaseUrl: null, - wsBaseUrl: null, - }); - continue; - } - const { bootstrap, httpBaseUrl } = config.value; - const runningDistro = config.value.runningDistro ?? null; +const readLocalEnvironmentBootstrapSnapshot = Effect.fn( + "desktop.ipc.window.getLocalEnvironmentBootstraps", +)(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const instances = yield* pool.list; + const bootstraps: DesktopEnvironmentBootstrap[] = []; + for (const instance of instances) { + const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; + const config = yield* instance.currentConfig; + const snapshot = yield* instance.snapshot; + // A secondary backend (e.g. a parallel WSL backend) that hasn't produced + // a config yet (mid-registration, before its first start cycle) or that + // is retrying a *transient* preflight failure (WSL VM still booting, a + // not-yet-built linux server entry) is not listening on a port. We + // surface it as a *pending* bootstrap (null endpoints, no token) so the + // renderer can show a "Connecting…" indicator while it retries — null + // endpoints keep the renderer from dialing the dead port, avoiding the + // needless /api/auth/bootstrap/bearer error cycles a real endpoint would + // trigger. + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { + // Skip the primary (same-origin, no "connecting" affordance) and skip a + // secondary whose preflight failed *fatally* (no node, wrong version, + // missing build tools): it has stopped retrying, so an indefinite + // "Connecting…" would be misleading — its error is surfaced by the + // WSL-state UI instead. + const fatalPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + config.value.preflightFailure.value.fatal; + const stoppedPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + (!snapshot.desiredRunning || !snapshot.restartScheduled); + if (isPrimary || fatalPreflight || stoppedPreflight) continue; bootstraps.push({ id: instance.id, - label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, - runningDistro, - httpBaseUrl: httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token - // the secret derives for the current window, so hand out that one - // rather than the token frozen into its launch config. Every backend - // the desktop launches (primary, staged or mounted WSL runtime) is the - // server build bundled with this desktop, so it understands the secret. - ...(bootstrap.desktopBootstrapSecret - ? { bootstrapToken: yield* configuration.currentBootstrapToken } - : bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), + label: yield* instance.label, + runningDistro: null, + httpBaseUrl: null, + wsBaseUrl: null, }); + continue; } - return bootstraps; - }), + const { bootstrap, httpBaseUrl } = config.value; + const runningDistro = config.value.runningDistro ?? null; + bootstraps.push({ + id: instance.id, + label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, + runningDistro, + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + // A backend launched with the desktop secret accepts whichever token + // the secret derives for the current window, so hand out that one + // rather than the token frozen into its launch config. Every backend + // the desktop launches (primary, staged or mounted WSL runtime) is the + // server build bundled with this desktop, so it understands the secret. + ...(bootstrap.desktopBootstrapSecret + ? { bootstrapToken: yield* configuration.currentBootstrapToken } + : bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }); + } + return bootstraps; +}); + +export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: readLocalEnvironmentBootstrapSnapshot, +}); + +export const readLocalEnvironmentBootstraps = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: readLocalEnvironmentBootstrapSnapshot, }); // Pull the distro selection out of a backend instance id like diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa3864..6ddcf29b4e04 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -12,6 +12,7 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; +import { createPreloadSnapshot } from "./preloadSnapshot.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -76,6 +77,19 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } +const readBootstraps = ( + result: unknown, +): ReturnType => + Array.isArray(result) ? result : []; +const localEnvironmentBootstraps = createPreloadSnapshot( + () => readBootstraps(ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), + async () => + readBootstraps(await ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), +); +// This setting changes only through a relaunch; repeated renderer reads do +// not need to block on the main process. +let localEnvironmentEnabled: boolean | undefined; + contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -102,17 +116,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, - getLocalEnvironmentBootstraps: () => { - const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); - if (!Array.isArray(result)) { - return []; - } - return result as ReturnType; - }, + getLocalEnvironmentBootstraps: localEnvironmentBootstraps.read, + readLocalEnvironmentBootstraps: localEnvironmentBootstraps.refresh, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getLocalEnvironmentEnabled: () => - ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, + (localEnvironmentEnabled ??= + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false), setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), diff --git a/apps/desktop/src/preloadSnapshot.test.ts b/apps/desktop/src/preloadSnapshot.test.ts new file mode 100644 index 000000000000..fe3c1963a999 --- /dev/null +++ b/apps/desktop/src/preloadSnapshot.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from "vite-plus/test"; + +import { createPreloadSnapshot } from "./preloadSnapshot.ts"; + +it("keeps reads responsive while the main process is busy and shares a refresh", async () => { + let synchronousReads = 0; + let asynchronousReads = 0; + let complete!: (value: string[]) => void; + const latest = new Promise((resolve) => { + complete = resolve; + }); + const snapshot = createPreloadSnapshot( + () => { + synchronousReads++; + return ["primary"]; + }, + () => { + asynchronousReads++; + return latest; + }, + ); + + expect(snapshot.read()).toEqual(["primary"]); + const refresh = snapshot.refresh(); + expect(snapshot.refresh()).toBe(refresh); + for (let read = 0; read < 1_000; read++) expect(snapshot.read()).toEqual(["primary"]); + expect(synchronousReads).toBe(1); + expect(asynchronousReads).toBe(1); + + complete(["primary", "wsl"]); + await refresh; + expect(snapshot.read()).toEqual(["primary", "wsl"]); + expect(synchronousReads).toBe(1); +}); + +it("preserves the last successful topology after IPC fails and recovers on the next refresh", async () => { + let fail = true; + const snapshot = createPreloadSnapshot( + () => ["primary", "wsl"], + async () => { + if (fail) throw new Error("IPC unavailable"); + return []; + }, + ); + expect(snapshot.read()).toEqual(["primary", "wsl"]); + await expect(snapshot.refresh()).rejects.toThrow("IPC unavailable"); + expect(snapshot.read()).toEqual(["primary", "wsl"]); + fail = false; + await snapshot.refresh(); + expect(snapshot.read()).toEqual([]); +}); + +it("uses an asynchronous startup result without a synchronous IPC read", async () => { + const snapshot = createPreloadSnapshot( + () => { + throw new Error("unexpected synchronous IPC"); + }, + async () => ["wsl"], + ); + await snapshot.refresh(); + expect(snapshot.read()).toEqual(["wsl"]); +}); diff --git a/apps/desktop/src/preloadSnapshot.ts b/apps/desktop/src/preloadSnapshot.ts new file mode 100644 index 000000000000..dcb446b43751 --- /dev/null +++ b/apps/desktop/src/preloadSnapshot.ts @@ -0,0 +1,24 @@ +/** Keep synchronous startup consumers while refreshing through nonblocking IPC. */ +export function createPreloadSnapshot(readInitial: () => T, readLatest: () => Promise) { + let snapshot: { readonly value: T } | undefined; + let pending: Promise | undefined; + + return { + read: (): T => { + snapshot ??= { value: readInitial() }; + return snapshot.value; + }, + refresh: (): Promise => { + if (pending !== undefined) return pending; + pending = readLatest() + .then((value) => { + snapshot = { value }; + return value; + }) + .finally(() => { + pending = undefined; + }); + return pending; + }, + }; +} diff --git a/apps/web/src/connection/desktopLocal.test.ts b/apps/web/src/connection/desktopLocal.test.ts index 7a31aa182099..f5f5fe578ad1 100644 --- a/apps/web/src/connection/desktopLocal.test.ts +++ b/apps/web/src/connection/desktopLocal.test.ts @@ -55,6 +55,47 @@ describe("desktop local connection identity", () => { }); describe("desktop local topology reads", () => { + it("refreshes asynchronously without calling the synchronous bridge and preserves failures", async () => { + const secondary: DesktopEnvironmentBootstrap = { + id: "wsl:default", + label: "WSL", + runningDistro: "Ubuntu", + httpBaseUrl: "http://127.0.0.1:4000", + wsBaseUrl: "ws://127.0.0.1:4000", + bootstrapToken: "bootstrap-1", + }; + let entries = [secondary]; + let failure: Error | undefined; + const reader = createDesktopSecondaryBootstrapsReader(() => ({ + getLocalEnvironmentBootstraps: () => { + throw new Error("synchronous IPC unavailable"); + }, + readLocalEnvironmentBootstraps: async () => { + if (failure !== undefined) throw failure; + return entries.map((entry) => ({ ...entry })); + }, + })); + const connected = await reader.refresh(); + expect(connected).toEqual({ _tag: "Success", bootstraps: [secondary] }); + if (connected._tag !== "Success") throw new Error("expected connected topology"); + expect(reader.readSnapshot()).toBe(connected.bootstraps); + expect(await reader.refresh()).toEqual(connected); + failure = new Error("main process unavailable"); + expect(await reader.refresh()).toEqual({ _tag: "Failure", cause: failure }); + expect(reader.readSnapshot()).toBe(connected.bootstraps); + failure = undefined; + entries = []; + expect(await reader.refresh()).toEqual({ _tag: "Success", bootstraps: [] }); + expect(reader.readSnapshot()).toEqual([]); + }); + + it("refreshes through an older desktop's synchronous bridge", async () => { + const reader = createDesktopSecondaryBootstrapsReader(() => ({ + getLocalEnvironmentBootstraps: () => [], + })); + expect(await reader.refresh()).toEqual({ _tag: "Success", bootstraps: [] }); + }); + it("reuses snapshots when polling returns fresh objects for the same topology", () => { const secondary: DesktopEnvironmentBootstrap = { id: "wsl:default", diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index 515146d73ed6..2a918856bb9c 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -54,6 +54,7 @@ export type DesktopSecondaryBootstrapsRead = export interface DesktopSecondaryBootstrapsReader { readonly readResult: () => DesktopSecondaryBootstrapsRead; + readonly refresh: () => Promise; readonly readSnapshot: () => ReadonlyArray; } @@ -64,10 +65,36 @@ export interface DesktopSecondaryBootstrapsReader { * platform's retained registrations. */ export function createDesktopSecondaryBootstrapsReader( - resolveBridge: () => Pick | undefined, + resolveBridge: () => + | Pick + | undefined, ): DesktopSecondaryBootstrapsReader { let snapshot: ReadonlyArray = []; + const updateSnapshot = ( + entries: ReadonlyArray, + ): DesktopSecondaryBootstrapsRead => { + const next = entries.filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); + if ( + next.length !== snapshot.length || + next.some((entry, index) => { + const previous = snapshot[index]; + return ( + previous === undefined || + entry.id !== previous.id || + entry.label !== previous.label || + entry.runningDistro !== previous.runningDistro || + entry.httpBaseUrl !== previous.httpBaseUrl || + entry.wsBaseUrl !== previous.wsBaseUrl || + entry.bootstrapToken !== previous.bootstrapToken + ); + }) + ) { + snapshot = next; + } + return { _tag: "Success", bootstraps: snapshot }; + }; + const readResult = (): DesktopSecondaryBootstrapsRead => { const bridge = resolveBridge(); if (bridge === undefined) { @@ -75,27 +102,7 @@ export function createDesktopSecondaryBootstrapsReader( return { _tag: "Success", bootstraps: snapshot }; } try { - const next = bridge - .getLocalEnvironmentBootstraps() - .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); - if ( - next.length !== snapshot.length || - next.some((entry, index) => { - const previous = snapshot[index]; - return ( - previous === undefined || - entry.id !== previous.id || - entry.label !== previous.label || - entry.runningDistro !== previous.runningDistro || - entry.httpBaseUrl !== previous.httpBaseUrl || - entry.wsBaseUrl !== previous.wsBaseUrl || - entry.bootstrapToken !== previous.bootstrapToken - ); - }) - ) { - snapshot = next; - } - return { _tag: "Success", bootstraps: snapshot }; + return updateSnapshot(bridge.getLocalEnvironmentBootstraps()); } catch (cause) { return { _tag: "Failure", cause }; } @@ -103,6 +110,15 @@ export function createDesktopSecondaryBootstrapsReader( return { readResult, + refresh: async () => { + const bridge = resolveBridge(); + if (bridge?.readLocalEnvironmentBootstraps === undefined) return readResult(); + try { + return updateSnapshot(await bridge.readLocalEnvironmentBootstraps()); + } catch (cause) { + return { _tag: "Failure", cause }; + } + }, readSnapshot: () => { const result = readResult(); return result._tag === "Success" ? result.bootstraps : snapshot; @@ -115,8 +131,8 @@ const desktopSecondaryBootstrapsReader = createDesktopSecondaryBootstrapsReader( ); /** Read the topology while preserving failures for platform cache policy. */ -export function readDesktopSecondaryBootstrapsResult(): DesktopSecondaryBootstrapsRead { - return desktopSecondaryBootstrapsReader.readResult(); +export function readDesktopSecondaryBootstrapsResult(): Promise { + return desktopSecondaryBootstrapsReader.refresh(); } /** Read the latest successful topology snapshot for renderer consumers. */ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 63c6563aea70..3c57abbb92ef 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -624,7 +624,7 @@ const layerPlatformConnectionSource = Layer.effect( } } - const topologyRead = readDesktopSecondaryBootstrapsResult(); + const topologyRead = yield* Effect.promise(readDesktopSecondaryBootstrapsResult); for (const [id, cached] of secondaryRegistrationsToRetainAfterTopologyRead( previous, topologyRead, diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 042fe07b44c8..cf5528c237aa 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -88,7 +88,10 @@ viewport, including a small overscan region. Cached badges remain available whil released. Local Git status reads skip divergence calculations; callers that need ahead/behind counts keep the full status path. Cached fetch failures share the existing backoff and log only the actual failed attempt. Unchanged desktop environment bootstrap reads retain their array -identity so polling does not invalidate subscribers. +identity so polling does not invalidate subscribers. The desktop preload keeps the synchronous +startup snapshot, while topology polling refreshes it through asynchronous IPC. Recurring +`sendSync` calls block the renderer behind unrelated main-process work, even when the returned +snapshot is unchanged. Local trace records and provider event logs use asynchronous, serialized file writes and rotation. Batching synchronous appends still stalls the server event loop on slow storage, especially on diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bea353c2bef4..f0825b956be9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1142,6 +1142,8 @@ export interface DesktopBridge { // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; + /** Refresh the preload snapshot without blocking the renderer on synchronous IPC. */ + readLocalEnvironmentBootstraps?: () => Promise; getLocalEnvironmentEnabled?: () => boolean; setLocalEnvironmentEnabled?: (enabled: boolean) => Promise; getLocalEnvironmentBearerToken: () => Promise; From d526d08eb13ed9dfb26041664c89dd1865d28abf Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Wed, 14 Oct 2026 13:38:04 -0300 Subject: [PATCH 2/3] fix(desktop): push local backend topology changes instead of polling over IPC The renderer re-read getLocalEnvironmentBootstraps on timers: the connection platform every 3 s and useDesktopLocalBootstraps every 2 s per consumer. Even with the async refresh, every read is a main-process IPC round trip that writes four desktop trace spans, so an idle app fills desktop.trace.ndjson. The backend manager's lifecycle state and the pool registry are now SubscriptionRefs, and the pool exposes their combined changes. The main process recomputes the bootstraps on each change and at each bootstrap-token window boundary, and pushes them to every window only when they differ. The preload stores pushed values in its cached snapshot, so getLocalEnvironment- Bootstraps costs no IPC after the first read, and exposes onLocalEnvironmentBootstrapsChange for renderer subscribers. The hook now re-reads on that event instead of an interval. The platform tick stays for discovery retries and bearer refresh but only reads the cache, so the async readLocalEnvironmentBootstraps refresh is no longer needed and is removed. Fixes #19505 --- .../src/backend/DesktopBackendManager.test.ts | 1 + .../src/backend/DesktopBackendManager.ts | 59 +++++++----- .../src/backend/DesktopBackendPool.test.ts | 43 +++++++++ .../desktop/src/backend/DesktopBackendPool.ts | 33 +++++-- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 +- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 83 ++++++++++++++++ apps/desktop/src/ipc/methods/window.ts | 57 ++++++++++- apps/desktop/src/preload.ts | 17 +++- apps/desktop/src/preloadSnapshot.test.ts | 67 ++++--------- apps/desktop/src/preloadSnapshot.ts | 22 ++--- .../desktop/src/updates/updatesTestHarness.ts | 2 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 3 + apps/web/src/connection/desktopLocal.test.ts | 41 -------- apps/web/src/connection/desktopLocal.ts | 64 +++++-------- apps/web/src/connection/platform.ts | 9 +- .../useDesktopLocalBootstraps.test.tsx | 96 +++++++++++++++++++ .../connection/useDesktopLocalBootstraps.ts | 13 +-- docs/internals/performance-regressions.md | 8 +- packages/contracts/src/ipc.ts | 6 +- 20 files changed, 425 insertions(+), 204 deletions(-) create mode 100644 apps/web/src/connection/useDesktopLocalBootstraps.test.tsx diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 760e5a24f897..c89bdc4cb1b0 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -1585,6 +1585,7 @@ describe("DesktopBackendManager", () => { list: Effect.succeed([instance1, instance2]), get: () => Effect.succeedNone, primary: Effect.die(new Error("primary not implemented")), + changes: Stream.empty, register: () => Effect.die(new Error("register not implemented")), unregister: () => Effect.die(new Error("unregister not implemented")), }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index c758b27dd282..5f1b227d4331 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -32,7 +32,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -276,6 +276,10 @@ export interface DesktopBackendInstance { readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; + // Emits once on subscribe and again after every lifecycle state write + // (start, ready, exit, restart scheduling). Writes that leave the observable + // config and snapshot unchanged still emit, so consumers dedupe. + readonly changes: Stream.Stream; // Polls desiredRunning + the instance's own ready flag until the // backend reports ready, or the timeout elapses. Returns true on // ready, false on timeout. Used by the WSL backend swap to drive its @@ -696,16 +700,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const state = yield* Ref.make(initialState); + const state = yield* SubscriptionRef.make(initialState); const mutex = yield* Semaphore.make(1); const { logWarning: logInstanceWarning, logError: logInstanceError } = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => - Ref.update(state, withActiveRun(runId, f)); + SubscriptionRef.update(state, withActiveRun(runId, f)); - const snapshot = Ref.get(state).pipe( + const snapshot = SubscriptionRef.get(state).pipe( Effect.map((current): DesktopBackendSnapshot => ({ desiredRunning: current.desiredRunning, ready: current.ready, @@ -714,10 +718,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( restartScheduled: Option.isSome(current.restartFiber), })), ); - const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); + const currentConfig = SubscriptionRef.get(state).pipe(Effect.map((current) => current.config)); + const changes = SubscriptionRef.changes(state).pipe(Stream.map(() => undefined)); const cancelRestart = Effect.gen(function* () { - const restartFiber = yield* Ref.modify(state, (current) => [ + const restartFiber = yield* SubscriptionRef.modify(state, (current) => [ current.restartFiber, { ...current, @@ -734,10 +739,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { - const current = yield* Ref.get(state); + const current = yield* SubscriptionRef.get(state); if (Option.isSome(current.active)) { if (!current.desiredRunning) { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, })); @@ -747,7 +752,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (current.ready) { yield* spec.onShutdown?.() ?? Effect.void; - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.ready ? { ...latest, ready: false } : latest, ); } @@ -772,7 +777,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const resetFatalPreflightCounter = !current.desiredRunning && current.preflightFailureAttempt > 0; yield* cancelRestart; - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, @@ -787,7 +792,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( // Transient (WSL cold-starting, wslpath while the VM boots). Keep // retrying so the backend self-heals once WSL is ready. Reset a // prior bounded/fatal streak because this is a different failure. - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, @@ -796,7 +801,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS; - const attempt = yield* Ref.modify(state, (latest) => { + const attempt = yield* SubscriptionRef.modify(state, (latest) => { const next = latest.preflightFailureAttempt + 1; return [next, { ...latest, preflightFailureAttempt: next }] as const; }); @@ -808,7 +813,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( reason, attempt, }); - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -830,7 +835,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (shouldRestart) { yield* scheduleRestart(reason); } else { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -843,7 +848,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } // Clean preflight — reset the fatal counter so a later failure gets a // fresh allowance. - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); @@ -853,7 +858,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } const runScope = yield* Scope.make("sequential"); - const runId = yield* Ref.modify(state, (latest) => [ + const runId = yield* SubscriptionRef.modify(state, (latest) => [ latest.nextRunId, { ...latest, @@ -877,7 +882,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* mutex.withPermits(1)( Effect.gen(function* () { const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = - yield* Ref.modify( + yield* SubscriptionRef.modify( state, ( latest, @@ -944,7 +949,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (isCurrentRun && nextState.desiredRunning) { if (stateDirOwned && !stopRequested) { - yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false })); + yield* SubscriptionRef.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + })); yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore); return; } @@ -977,7 +985,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( exitObserved: true, })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { - const isCurrentRun = yield* Ref.modify(state, (latest) => { + const isCurrentRun = yield* SubscriptionRef.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { return [false, latest] as const; @@ -1041,7 +1049,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, ) { - const scheduled = yield* Ref.modify(state, (latest) => { + const scheduled = yield* SubscriptionRef.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { return [Option.none(), latest] as const; } @@ -1066,7 +1074,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( - Ref.modify(state, (latest) => { + SubscriptionRef.modify(state, (latest) => { const shouldRestart = latest.desiredRunning; return [ shouldRestart, @@ -1086,7 +1094,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ), parentScope, ); - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => Option.isNone(latest.restartFiber) ? { ...latest, @@ -1103,7 +1111,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }) { const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( Effect.gen(function* () { - const result = yield* Ref.modify(state, (latest) => { + const result = yield* SubscriptionRef.modify(state, (latest) => { const active = Option.map(latest.active, (run) => run.exitObserved ? run : { ...run, stopRequested: true }, ); @@ -1142,7 +1150,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const cleanup = yield* mutex.withPermits(1)( - Ref.modify( + SubscriptionRef.modify( state, ( latest, @@ -1192,7 +1200,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const waitForReady = (timeout: Duration.Duration): Effect.Effect => Effect.gen(function* () { - const current = yield* Ref.get(state); + const current = yield* SubscriptionRef.get(state); // Return false early if an external `stop()` flipped desiredRunning off // — no point polling for a backend that is being torn down. if (!current.desiredRunning) return { done: true, ready: false }; @@ -1216,6 +1224,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( stop, currentConfig, snapshot, + changes, waitForReady, } satisfies DesktopBackendInstance; }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index c5f31fc47a09..79fac4065399 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; @@ -39,6 +40,7 @@ function makeStubInstance( stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(snapshot), + changes: Stream.empty, waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), }; } @@ -158,4 +160,45 @@ describe("DesktopBackendPool", () => { }), ), ); + + it.effect("reports registry changes so the renderer topology can be re-read", () => + Effect.scoped( + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( + Effect.provide(layerPool(yield* Ref.make("Windows"))), + ); + const wslId = DesktopBackendPool.BackendInstanceId("wsl:ubuntu"); + // Each emission records the registry as observed right after it, so + // only an emission caused by the change can satisfy the next wait. + const observed = yield* Queue.unbounded>(); + yield* pool.changes.pipe( + Stream.runForEach(() => + pool.list.pipe( + Effect.flatMap((instances) => + Queue.offer( + observed, + instances.map((instance) => instance.id), + ), + ), + ), + ), + Effect.forkScoped, + ); + const awaitRegistry = (matches: (ids: ReadonlyArray) => boolean) => + Effect.gen(function* () { + while (!matches(yield* Queue.take(observed))) {} + }); + + yield* awaitRegistry((ids) => ids.length === 1); + yield* pool.register({ + id: wslId, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.die("unexpected WSL config resolve"), + }); + yield* awaitRegistry((ids) => ids.includes(wslId)); + yield* pool.unregister(wslId); + yield* awaitRegistry((ids) => !ids.includes(wslId)); + }), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 21845205110c..a11ffdcb948d 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -87,7 +87,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as FileSystem from "effect/FileSystem"; import { HttpClient } from "effect/http"; @@ -153,6 +154,10 @@ export class DesktopBackendPool extends Context.Service< // exposed as a typed effect so consumers don't have to handle the // Option for the case that's guaranteed to be present. readonly primary: Effect.Effect; + // Emits on subscribe and whenever an instance registers, unregisters or + // changes lifecycle state: the triggers for re-reading the renderer's + // local environment bootstraps. Emissions can repeat; consumers dedupe. + readonly changes: Stream.Stream; // Build a fresh DesktopBackendInstance from `spec` and add it to the // registry. The pool owns the instance's scope: unregister(id) or pool // teardown closes it and runs the instance's auto-stop finalizer. The @@ -314,7 +319,7 @@ export const layer = Layer.effect( .pipe(Effect.andThen(electronApp.quit)), }); - const instancesRef = yield* SynchronizedRef.make< + const instancesRef = yield* SubscriptionRef.make< ReadonlyMap >( new Map([ @@ -327,7 +332,7 @@ export const layer = Layer.effect( const register: DesktopBackendPool["Service"]["register"] = (spec) => Effect.suspend(() => - SynchronizedRef.modifyEffect( + SubscriptionRef.modifyEffect( instancesRef, ( current, @@ -382,7 +387,7 @@ export const layer = Layer.effect( return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); } const done = yield* Deferred.make(); - const action = yield* SynchronizedRef.modifyEffect( + const action = yield* SubscriptionRef.modifyEffect( instancesRef, ( current, @@ -414,7 +419,7 @@ export const layer = Layer.effect( return; } - const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { + const finish = SubscriptionRef.modifyEffect(instancesRef, (current) => { const closing = current.get(id); if (closing?._tag !== "Closing" || closing.done !== done) { return Effect.succeed([undefined, current] as const); @@ -434,13 +439,13 @@ export const layer = Layer.effect( return DesktopBackendPool.of({ get: (id) => - SynchronizedRef.get(instancesRef).pipe( + SubscriptionRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), ), - list: SynchronizedRef.get(instancesRef).pipe( + list: SubscriptionRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => entry._tag === "Active" ? [entry.instance] : [], @@ -448,6 +453,16 @@ export const layer = Layer.effect( ), ), primary: Effect.succeed(primary), + changes: SubscriptionRef.changes(instancesRef).pipe( + Stream.switchMap((instances) => + Stream.mergeAll( + Array.from(instances.values()).flatMap((entry) => + entry._tag === "Active" ? [entry.instance.changes] : [], + ), + { concurrency: "unbounded" }, + ), + ), + ), register, unregister, }); @@ -478,6 +493,10 @@ export const layerTest = ( get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), + changes: Stream.mergeAll( + instances.map((instance) => instance.changes), + { concurrency: "unbounded" }, + ), register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 80058008e46c..6107cbae1b54 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,7 +40,7 @@ import { import { getAppBranding, getLocalEnvironmentBootstraps, - readLocalEnvironmentBootstraps, + installLocalEnvironmentBootstrapsForwarding, getLocalEnvironmentBearerToken, getSystemLocale, getWindowFullscreenState, @@ -87,6 +87,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" const ipc = yield* DesktopIpc.DesktopIpc; yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); + yield* installLocalEnvironmentBootstrapsForwarding(); yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); @@ -98,7 +99,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); - yield* ipc.handle(readLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b57..7419b4914387 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -31,6 +31,7 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; +export const LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index d21f3cd60505..e233ab464cd2 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,11 +1,19 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS } from "@t3tools/shared/desktopBootstrapToken"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import type * as Electron from "electron"; @@ -25,9 +33,11 @@ import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + installLocalEnvironmentBootstrapsForwarding, pasteAsText, pickProjectFavicon, probeRemoteEditors, @@ -69,6 +79,7 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { restartAttempt: 0, restartScheduled: false, }), + changes: Stream.empty, waitForReady: () => Effect.succeed(true), }; @@ -197,6 +208,78 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); +describe("installLocalEnvironmentBootstrapsForwarding", () => { + it.effect("pushes only changed bootstraps and survives a failed read", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* Ref.make({ + ...readyWslConfig, + bootstrap: { ...readyWslConfig.bootstrap, desktopBootstrapSecret: "desktop-secret" }, + }); + const tokenReadFails = yield* Ref.make(false); + const lifecycle = yield* SubscriptionRef.make(0); + const sent = yield* Queue.unbounded(); + const changeLifecycle = SubscriptionRef.update(lifecycle, (count) => count + 1); + const takePort = Queue.take(sent).pipe( + Effect.map(([channel, bootstraps]) => { + assert.equal(channel, IpcChannels.LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); + return bootstraps as ReadonlyArray<{ httpBaseUrl: string; bootstrapToken: string }>; + }), + ); + + yield* installLocalEnvironmentBootstrapsForwarding().pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([ + { + ...defaultWslInstance, + currentConfig: Ref.get(config).pipe(Effect.asSome), + changes: SubscriptionRef.changes(lifecycle).pipe(Stream.map(() => undefined)), + }, + ]), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + resolveWsl: () => Effect.die("unexpected resolveWsl"), + currentBootstrapToken: Effect.gen(function* () { + if (yield* Ref.get(tokenReadFails)) return yield* Effect.die("token unavailable"); + const now = yield* Clock.currentTimeMillis; + return `token-${Math.floor(now / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS)}`; + }), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), + Layer.mock(ElectronWindow.ElectronWindow)({ + sendAll: (channel, bootstraps) => Queue.offer(sent, [channel, bootstraps]), + }), + ), + ), + ); + + const initial = yield* takePort; + assert.equal(initial[0]?.httpBaseUrl, "http://127.0.0.1:3774/"); + assert.equal(initial[0]?.bootstrapToken, "token-0"); + + // A lifecycle write that leaves the bootstraps unchanged and a failed + // read both send nothing, so the next message is the real change. + yield* changeLifecycle; + yield* Ref.set(tokenReadFails, true); + yield* changeLifecycle; + yield* Ref.set(tokenReadFails, false); + yield* Ref.update(config, (current) => ({ + ...current, + httpBaseUrl: new URL("http://127.0.0.1:3775"), + })); + yield* changeLifecycle; + assert.equal((yield* takePort)[0]?.httpBaseUrl, "http://127.0.0.1:3775/"); + + // The token rolls over with no lifecycle change. + yield* TestClock.adjust(Duration.millis(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS)); + assert.equal((yield* takePort)[0]?.bootstrapToken, "token-1"); + assert.equal(yield* Queue.size(sent), 0); + }), + ), + ); +}); + describe("getWindowFullscreenState", () => { it.effect("reads the current native window state", () => { const window = { isFullScreen: () => true } as Electron.BrowserWindow; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index c19e1c6c483d..b97e4efcecad 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -15,12 +15,16 @@ import { } from "@t3tools/contracts"; import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; +import { DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS } from "@t3tools/shared/desktopBootstrapToken"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; @@ -163,11 +167,54 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ handler: readLocalEnvironmentBootstrapSnapshot, }); -export const readLocalEnvironmentBootstraps = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - payload: Schema.Void, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: readLocalEnvironmentBootstrapSnapshot, +// The bootstrap token rolls over on a fixed time window without any backend +// state change. Wake at each boundary, and at least hourly so a boundary +// passed during system sleep is caught soon after resume. +const bootstrapTokenRollovers = Stream.fromEffectRepeat( + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + Effect.sleep( + Math.min( + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS - (now % DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS), + Duration.toMillis(Duration.hours(1)), + ), + ), + ), + ), +); + +/** + * Push the local environment bootstraps to every renderer whenever they change, + * so the preload's cached snapshot stays current and the renderer never polls + * the main process for topology. + */ +export const installLocalEnvironmentBootstrapsForwarding = Effect.fn( + "desktop.ipc.window.installLocalEnvironmentBootstrapsForwarding", +)(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const electronWindow = yield* ElectronWindow.ElectronWindow; + let lastSent: string | undefined; + yield* pool.changes.pipe( + Stream.merge(bootstrapTokenRollovers), + Stream.runForEach(() => + readLocalEnvironmentBootstrapSnapshot().pipe( + Effect.flatMap((bootstraps) => { + const serialized = JSON.stringify(bootstraps); + if (serialized === lastSent) return Effect.void; + lastSent = serialized; + return electronWindow.sendAll( + IpcChannels.LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + bootstraps, + ); + }), + // Keep forwarding after a failed read; the next change retries it. + Effect.catchCause((cause) => + Effect.logWarning("Could not publish local environment bootstraps", { cause }), + ), + ), + ), + Effect.forkScoped, + ); }); // Pull the distro selection out of a backend instance id like diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6ddcf29b4e04..da97e5b45cf6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -81,11 +81,13 @@ const readBootstraps = ( result: unknown, ): ReturnType => Array.isArray(result) ? result : []; -const localEnvironmentBootstraps = createPreloadSnapshot( - () => readBootstraps(ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), - async () => - readBootstraps(await ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), +const localEnvironmentBootstraps = createPreloadSnapshot(() => + readBootstraps(ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), ); +// Registered before any app listener so they all observe the updated cache. +ipcRenderer.on(IpcChannels.LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, (_event, bootstraps: unknown) => { + if (Array.isArray(bootstraps)) localEnvironmentBootstraps.set(readBootstraps(bootstraps)); +}); // This setting changes only through a relaunch; repeated renderer reads do // not need to block on the main process. let localEnvironmentEnabled: boolean | undefined; @@ -117,7 +119,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { return typeof result === "string" ? result : null; }, getLocalEnvironmentBootstraps: localEnvironmentBootstraps.read, - readLocalEnvironmentBootstraps: localEnvironmentBootstraps.refresh, + onLocalEnvironmentBootstrapsChange: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, handler); + return () => + ipcRenderer.removeListener(IpcChannels.LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, handler); + }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getLocalEnvironmentEnabled: () => diff --git a/apps/desktop/src/preloadSnapshot.test.ts b/apps/desktop/src/preloadSnapshot.test.ts index fe3c1963a999..5ec830fc3b28 100644 --- a/apps/desktop/src/preloadSnapshot.test.ts +++ b/apps/desktop/src/preloadSnapshot.test.ts @@ -2,61 +2,34 @@ import { expect, it } from "vite-plus/test"; import { createPreloadSnapshot } from "./preloadSnapshot.ts"; -it("keeps reads responsive while the main process is busy and shares a refresh", async () => { +it("reads synchronously once, then serves pushed values without IPC", () => { let synchronousReads = 0; - let asynchronousReads = 0; - let complete!: (value: string[]) => void; - const latest = new Promise((resolve) => { - complete = resolve; + const snapshot = createPreloadSnapshot(() => { + synchronousReads++; + return ["primary"]; }); - const snapshot = createPreloadSnapshot( - () => { - synchronousReads++; - return ["primary"]; - }, - () => { - asynchronousReads++; - return latest; - }, - ); - expect(snapshot.read()).toEqual(["primary"]); - const refresh = snapshot.refresh(); - expect(snapshot.refresh()).toBe(refresh); for (let read = 0; read < 1_000; read++) expect(snapshot.read()).toEqual(["primary"]); - expect(synchronousReads).toBe(1); - expect(asynchronousReads).toBe(1); - - complete(["primary", "wsl"]); - await refresh; + snapshot.set(["primary", "wsl"]); expect(snapshot.read()).toEqual(["primary", "wsl"]); expect(synchronousReads).toBe(1); }); -it("preserves the last successful topology after IPC fails and recovers on the next refresh", async () => { - let fail = true; - const snapshot = createPreloadSnapshot( - () => ["primary", "wsl"], - async () => { - if (fail) throw new Error("IPC unavailable"); - return []; - }, - ); - expect(snapshot.read()).toEqual(["primary", "wsl"]); - await expect(snapshot.refresh()).rejects.toThrow("IPC unavailable"); - expect(snapshot.read()).toEqual(["primary", "wsl"]); - fail = false; - await snapshot.refresh(); - expect(snapshot.read()).toEqual([]); +it("skips the synchronous read when a push arrives first", () => { + const snapshot = createPreloadSnapshot(() => { + throw new Error("unexpected synchronous IPC"); + }); + snapshot.set(["wsl"]); + expect(snapshot.read()).toEqual(["wsl"]); }); -it("uses an asynchronous startup result without a synchronous IPC read", async () => { - const snapshot = createPreloadSnapshot( - () => { - throw new Error("unexpected synchronous IPC"); - }, - async () => ["wsl"], - ); - await snapshot.refresh(); - expect(snapshot.read()).toEqual(["wsl"]); +it("retries the synchronous read after it fails", () => { + let fail = true; + const snapshot = createPreloadSnapshot(() => { + if (fail) throw new Error("IPC unavailable"); + return ["primary"]; + }); + expect(() => snapshot.read()).toThrow("IPC unavailable"); + fail = false; + expect(snapshot.read()).toEqual(["primary"]); }); diff --git a/apps/desktop/src/preloadSnapshot.ts b/apps/desktop/src/preloadSnapshot.ts index dcb446b43751..c99c066915ec 100644 --- a/apps/desktop/src/preloadSnapshot.ts +++ b/apps/desktop/src/preloadSnapshot.ts @@ -1,24 +1,18 @@ -/** Keep synchronous startup consumers while refreshing through nonblocking IPC. */ -export function createPreloadSnapshot(readInitial: () => T, readLatest: () => Promise) { +/** + * Cache a value the main process pushes whenever it changes. Only the first + * read before any push costs a synchronous IPC; a failed read leaves the cache + * empty so the next read retries. + */ +export function createPreloadSnapshot(readInitial: () => T) { let snapshot: { readonly value: T } | undefined; - let pending: Promise | undefined; return { read: (): T => { snapshot ??= { value: readInitial() }; return snapshot.value; }, - refresh: (): Promise => { - if (pending !== undefined) return pending; - pending = readLatest() - .then((value) => { - snapshot = { value }; - return value; - }) - .finally(() => { - pending = undefined; - }); - return pending; + set: (value: T): void => { + snapshot = { value }; }, }; } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index c3701280ad55..a0fa8b0068ce 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -145,6 +146,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { restartAttempt: 0, restartScheduled: false, }), + changes: Stream.empty, waitForReady: () => Effect.succeed(true), }; const layerBackend = DesktopBackendPool.layerTest([stubBackendInstance]); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index e6dcb7bd7133..d04f676aaecd 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as NetService from "@t3tools/shared/Net"; @@ -30,6 +31,7 @@ function makeStubInstance(input: { stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(input.snapshot), + changes: Stream.empty, waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), }; } @@ -125,6 +127,7 @@ describe("DesktopWslBackend", () => { ), list: Effect.sync(() => (registeredWsl === undefined ? [primary] : [primary, registeredWsl])), primary: Effect.succeed(primary), + changes: Stream.empty, register: (spec) => Effect.sync(() => { registeredSpec = spec; diff --git a/apps/web/src/connection/desktopLocal.test.ts b/apps/web/src/connection/desktopLocal.test.ts index f5f5fe578ad1..7a31aa182099 100644 --- a/apps/web/src/connection/desktopLocal.test.ts +++ b/apps/web/src/connection/desktopLocal.test.ts @@ -55,47 +55,6 @@ describe("desktop local connection identity", () => { }); describe("desktop local topology reads", () => { - it("refreshes asynchronously without calling the synchronous bridge and preserves failures", async () => { - const secondary: DesktopEnvironmentBootstrap = { - id: "wsl:default", - label: "WSL", - runningDistro: "Ubuntu", - httpBaseUrl: "http://127.0.0.1:4000", - wsBaseUrl: "ws://127.0.0.1:4000", - bootstrapToken: "bootstrap-1", - }; - let entries = [secondary]; - let failure: Error | undefined; - const reader = createDesktopSecondaryBootstrapsReader(() => ({ - getLocalEnvironmentBootstraps: () => { - throw new Error("synchronous IPC unavailable"); - }, - readLocalEnvironmentBootstraps: async () => { - if (failure !== undefined) throw failure; - return entries.map((entry) => ({ ...entry })); - }, - })); - const connected = await reader.refresh(); - expect(connected).toEqual({ _tag: "Success", bootstraps: [secondary] }); - if (connected._tag !== "Success") throw new Error("expected connected topology"); - expect(reader.readSnapshot()).toBe(connected.bootstraps); - expect(await reader.refresh()).toEqual(connected); - failure = new Error("main process unavailable"); - expect(await reader.refresh()).toEqual({ _tag: "Failure", cause: failure }); - expect(reader.readSnapshot()).toBe(connected.bootstraps); - failure = undefined; - entries = []; - expect(await reader.refresh()).toEqual({ _tag: "Success", bootstraps: [] }); - expect(reader.readSnapshot()).toEqual([]); - }); - - it("refreshes through an older desktop's synchronous bridge", async () => { - const reader = createDesktopSecondaryBootstrapsReader(() => ({ - getLocalEnvironmentBootstraps: () => [], - })); - expect(await reader.refresh()).toEqual({ _tag: "Success", bootstraps: [] }); - }); - it("reuses snapshots when polling returns fresh objects for the same topology", () => { const secondary: DesktopEnvironmentBootstrap = { id: "wsl:default", diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index 2a918856bb9c..515146d73ed6 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -54,7 +54,6 @@ export type DesktopSecondaryBootstrapsRead = export interface DesktopSecondaryBootstrapsReader { readonly readResult: () => DesktopSecondaryBootstrapsRead; - readonly refresh: () => Promise; readonly readSnapshot: () => ReadonlyArray; } @@ -65,36 +64,10 @@ export interface DesktopSecondaryBootstrapsReader { * platform's retained registrations. */ export function createDesktopSecondaryBootstrapsReader( - resolveBridge: () => - | Pick - | undefined, + resolveBridge: () => Pick | undefined, ): DesktopSecondaryBootstrapsReader { let snapshot: ReadonlyArray = []; - const updateSnapshot = ( - entries: ReadonlyArray, - ): DesktopSecondaryBootstrapsRead => { - const next = entries.filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); - if ( - next.length !== snapshot.length || - next.some((entry, index) => { - const previous = snapshot[index]; - return ( - previous === undefined || - entry.id !== previous.id || - entry.label !== previous.label || - entry.runningDistro !== previous.runningDistro || - entry.httpBaseUrl !== previous.httpBaseUrl || - entry.wsBaseUrl !== previous.wsBaseUrl || - entry.bootstrapToken !== previous.bootstrapToken - ); - }) - ) { - snapshot = next; - } - return { _tag: "Success", bootstraps: snapshot }; - }; - const readResult = (): DesktopSecondaryBootstrapsRead => { const bridge = resolveBridge(); if (bridge === undefined) { @@ -102,7 +75,27 @@ export function createDesktopSecondaryBootstrapsReader( return { _tag: "Success", bootstraps: snapshot }; } try { - return updateSnapshot(bridge.getLocalEnvironmentBootstraps()); + const next = bridge + .getLocalEnvironmentBootstraps() + .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); + if ( + next.length !== snapshot.length || + next.some((entry, index) => { + const previous = snapshot[index]; + return ( + previous === undefined || + entry.id !== previous.id || + entry.label !== previous.label || + entry.runningDistro !== previous.runningDistro || + entry.httpBaseUrl !== previous.httpBaseUrl || + entry.wsBaseUrl !== previous.wsBaseUrl || + entry.bootstrapToken !== previous.bootstrapToken + ); + }) + ) { + snapshot = next; + } + return { _tag: "Success", bootstraps: snapshot }; } catch (cause) { return { _tag: "Failure", cause }; } @@ -110,15 +103,6 @@ export function createDesktopSecondaryBootstrapsReader( return { readResult, - refresh: async () => { - const bridge = resolveBridge(); - if (bridge?.readLocalEnvironmentBootstraps === undefined) return readResult(); - try { - return updateSnapshot(await bridge.readLocalEnvironmentBootstraps()); - } catch (cause) { - return { _tag: "Failure", cause }; - } - }, readSnapshot: () => { const result = readResult(); return result._tag === "Success" ? result.bootstraps : snapshot; @@ -131,8 +115,8 @@ const desktopSecondaryBootstrapsReader = createDesktopSecondaryBootstrapsReader( ); /** Read the topology while preserving failures for platform cache policy. */ -export function readDesktopSecondaryBootstrapsResult(): Promise { - return desktopSecondaryBootstrapsReader.refresh(); +export function readDesktopSecondaryBootstrapsResult(): DesktopSecondaryBootstrapsRead { + return desktopSecondaryBootstrapsReader.readResult(); } /** Read the latest successful topology snapshot for renderer consumers. */ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 3c57abbb92ef..3185551fbded 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -420,9 +420,10 @@ const loadSecondaryConnectionRegistration = Effect.fn( }; }); -// Poll cadence for the desktop bootstrap topology. There is no change event on -// the bridge, so the renderer polls; successful registrations are cached by a -// signature of their endpoint until bearer credentials approach expiry. +// Rebuild cadence for platform registrations. Desktop topology reads come from +// the preload cache the desktop pushes into, so this costs no IPC; the tick +// retries failed discovery and refreshes bearers as they approach expiry. +// Successful registrations are cached by a signature of their endpoint. const PLATFORM_POLL_INTERVAL = "3 seconds"; const SECONDARY_BEARER_REFRESH_SKEW_MS = 5_000; @@ -624,7 +625,7 @@ const layerPlatformConnectionSource = Layer.effect( } } - const topologyRead = yield* Effect.promise(readDesktopSecondaryBootstrapsResult); + const topologyRead = readDesktopSecondaryBootstrapsResult(); for (const [id, cached] of secondaryRegistrationsToRetainAfterTopologyRead( previous, topologyRead, diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx new file mode 100644 index 000000000000..cd573885002a --- /dev/null +++ b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx @@ -0,0 +1,96 @@ +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useDesktopLocalBootstraps } from "./useDesktopLocalBootstraps"; + +const wsl: DesktopEnvironmentBootstrap = { + id: "wsl:Ubuntu", + label: "WSL (Ubuntu)", + runningDistro: "Ubuntu", + httpBaseUrl: "http://127.0.0.1:4000", + wsBaseUrl: "ws://127.0.0.1:4000", +}; + +let root: Root; +let rendered: ReadonlyArray; +let readBootstraps: () => ReadonlyArray; +let bridgeReads: number; +let notifyChange: (() => void) | undefined; + +function BootstrapsProbe() { + const bootstraps = useDesktopLocalBootstraps(); + useLayoutEffect(() => { + rendered = bootstraps; + }, [bootstraps]); + return null; +} + +beforeEach(() => { + vi.useFakeTimers(); + // The probe renders no host nodes, but ReactDOM still needs an event target. + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + readBootstraps = () => [wsl]; + bridgeReads = 0; + notifyChange = undefined; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { + document, + HTMLIFrameElement: EventTarget, + desktopBridge: { + getLocalEnvironmentBootstraps: () => { + bridgeReads++; + return readBootstraps(); + }, + onLocalEnvironmentBootstrapsChange: (listener: () => void) => { + notifyChange = listener; + return () => { + notifyChange = undefined; + }; + }, + }, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it("re-reads on desktop change events instead of polling", async () => { + await act(() => root.render()); + expect(rendered).toEqual([wsl]); + + const readsAfterMount = bridgeReads; + await act(() => vi.advanceTimersByTime(60_000)); + expect(bridgeReads).toBe(readsAfterMount); + + const moved = { ...wsl, httpBaseUrl: "http://127.0.0.1:4001" }; + readBootstraps = () => [moved]; + await act(() => notifyChange?.()); + expect(rendered).toEqual([moved]); + + // A failed read keeps the last snapshot; a successful empty read clears it. + readBootstraps = () => { + throw new Error("IPC unavailable"); + }; + await act(() => notifyChange?.()); + expect(rendered).toEqual([moved]); + readBootstraps = () => []; + await act(() => notifyChange?.()); + expect(rendered).toEqual([]); + + await act(() => root.unmount()); + expect(notifyChange).toBeUndefined(); +}); diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.ts b/apps/web/src/connection/useDesktopLocalBootstraps.ts index 71642aecba46..a28767e92f9f 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.ts +++ b/apps/web/src/connection/useDesktopLocalBootstraps.ts @@ -3,14 +3,12 @@ import { useEffect, useState } from "react"; import { readDesktopSecondaryBootstraps } from "./desktopLocal"; -const DESKTOP_LOCAL_BOOTSTRAP_POLL_MS = 2_000; - /** * Reactively track the desktop's secondary local backends (e.g. a parallel WSL - * backend). The bridge exposes no change event, so we re-read on an interval; - * failed reads retain the latest successful snapshot, while a successful empty - * read clears it. Use this instead of polling the bridge ad hoc so every - * renderer consumer reads the same topology. + * backend). Re-reads when the desktop reports a topology change; failed reads + * retain the latest successful snapshot, while a successful empty read clears + * it. Use this instead of reading the bridge ad hoc so every renderer consumer + * reads the same topology. */ export function useDesktopLocalBootstraps(): ReadonlyArray { const [bootstraps, setBootstraps] = useState>( @@ -20,8 +18,7 @@ export function useDesktopLocalBootstraps(): ReadonlyArray { const read = () => setBootstraps(readDesktopSecondaryBootstraps()); read(); - const interval = setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); - return () => clearInterval(interval); + return window.desktopBridge?.onLocalEnvironmentBootstrapsChange?.(read); }, []); return bootstraps; diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index cf5528c237aa..50002d8fef6b 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -88,10 +88,10 @@ viewport, including a small overscan region. Cached badges remain available whil released. Local Git status reads skip divergence calculations; callers that need ahead/behind counts keep the full status path. Cached fetch failures share the existing backoff and log only the actual failed attempt. Unchanged desktop environment bootstrap reads retain their array -identity so polling does not invalidate subscribers. The desktop preload keeps the synchronous -startup snapshot, while topology polling refreshes it through asynchronous IPC. Recurring -`sendSync` calls block the renderer behind unrelated main-process work, even when the returned -snapshot is unchanged. +identity so polling does not invalidate subscribers. The desktop main process pushes bootstrap +changes into a preload cache, and renderer reads are served from that cache. Do not read topology +from the main process on a timer: recurring `sendSync` calls block the renderer behind unrelated +main-process work, and every IPC read writes desktop trace spans even when nothing changed. Local trace records and provider event logs use asynchronous, serialized file writes and rotation. Batching synchronous appends still stalls the server event loop on slow storage, especially on diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f0825b956be9..a84f34d29284 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1141,9 +1141,11 @@ export interface DesktopBridge { // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. + // Served from a preload cache the desktop keeps current by pushing every + // change, so repeated reads cost no IPC. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; - /** Refresh the preload snapshot without blocking the renderer on synchronous IPC. */ - readLocalEnvironmentBootstraps?: () => Promise; + /** Called after the bootstraps change; read them with `getLocalEnvironmentBootstraps`. */ + onLocalEnvironmentBootstrapsChange?: (listener: () => void) => () => void; getLocalEnvironmentEnabled?: () => boolean; setLocalEnvironmentEnabled?: (enabled: boolean) => Promise; getLocalEnvironmentBearerToken: () => Promise; From 7690ece84cf07c5bfe445291d69cfbcccb4b9f9b Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Wed, 14 Oct 2026 14:19:03 -0300 Subject: [PATCH 3/3] fix(web): keep polling local bootstraps on desktop shells without the change event onLocalEnvironmentBootstrapsChange is optional because an older desktop shell can host a newer web client. Without it the hook read once and went stale, so fall back to the previous 2 s interval there. --- .../useDesktopLocalBootstraps.test.tsx | 19 +++++++++++++++++++ .../connection/useDesktopLocalBootstraps.ts | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx index cd573885002a..b4250cd84213 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx +++ b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx @@ -94,3 +94,22 @@ it("re-reads on desktop change events instead of polling", async () => { await act(() => root.unmount()); expect(notifyChange).toBeUndefined(); }); + +it("polls a desktop shell that predates the change event", async () => { + const { onLocalEnvironmentBootstrapsChange: _, ...legacyBridge } = ( + window as unknown as { desktopBridge: Record } + ).desktopBridge; + vi.stubGlobal("window", { ...window, desktopBridge: legacyBridge }); + await act(() => root.render()); + expect(rendered).toEqual([wsl]); + + const moved = { ...wsl, httpBaseUrl: "http://127.0.0.1:4001" }; + readBootstraps = () => [moved]; + await act(() => vi.advanceTimersByTime(2_000)); + expect(rendered).toEqual([moved]); + + await act(() => root.unmount()); + const readsAfterUnmount = bridgeReads; + await act(() => vi.advanceTimersByTime(10_000)); + expect(bridgeReads).toBe(readsAfterUnmount); +}); diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.ts b/apps/web/src/connection/useDesktopLocalBootstraps.ts index a28767e92f9f..8f9ce431cd5f 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.ts +++ b/apps/web/src/connection/useDesktopLocalBootstraps.ts @@ -3,12 +3,16 @@ import { useEffect, useState } from "react"; import { readDesktopSecondaryBootstraps } from "./desktopLocal"; +// Only for desktop shells that predate the bootstraps change event. +const LEGACY_BRIDGE_POLL_MS = 2_000; + /** * Reactively track the desktop's secondary local backends (e.g. a parallel WSL - * backend). Re-reads when the desktop reports a topology change; failed reads - * retain the latest successful snapshot, while a successful empty read clears - * it. Use this instead of reading the bridge ad hoc so every renderer consumer - * reads the same topology. + * backend). Re-reads when the desktop reports a topology change, or on an + * interval for older shells without the event; failed reads retain the latest + * successful snapshot, while a successful empty read clears it. Use this + * instead of reading the bridge ad hoc so every renderer consumer reads the + * same topology. */ export function useDesktopLocalBootstraps(): ReadonlyArray { const [bootstraps, setBootstraps] = useState>( @@ -18,7 +22,10 @@ export function useDesktopLocalBootstraps(): ReadonlyArray { const read = () => setBootstraps(readDesktopSecondaryBootstraps()); read(); - return window.desktopBridge?.onLocalEnvironmentBootstrapsChange?.(read); + const subscribe = window.desktopBridge?.onLocalEnvironmentBootstrapsChange; + if (subscribe) return subscribe(read); + const interval = setInterval(read, LEGACY_BRIDGE_POLL_MS); + return () => clearInterval(interval); }, []); return bootstraps;