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 caed223e90e8..6107cbae1b54 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { import { getAppBranding, getLocalEnvironmentBootstraps, + installLocalEnvironmentBootstrapsForwarding, getLocalEnvironmentBearerToken, getSystemLocale, getWindowFullscreenState, @@ -86,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); 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 33895ffffbd1..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"; @@ -90,73 +94,127 @@ 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, +}); + +// 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 efd49ffa3864..da97e5b45cf6 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,21 @@ 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)), +); +// 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; + contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -102,17 +118,18 @@ 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, + 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: () => - 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..5ec830fc3b28 --- /dev/null +++ b/apps/desktop/src/preloadSnapshot.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vite-plus/test"; + +import { createPreloadSnapshot } from "./preloadSnapshot.ts"; + +it("reads synchronously once, then serves pushed values without IPC", () => { + let synchronousReads = 0; + const snapshot = createPreloadSnapshot(() => { + synchronousReads++; + return ["primary"]; + }); + + for (let read = 0; read < 1_000; read++) expect(snapshot.read()).toEqual(["primary"]); + snapshot.set(["primary", "wsl"]); + expect(snapshot.read()).toEqual(["primary", "wsl"]); + expect(synchronousReads).toBe(1); +}); + +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("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 new file mode 100644 index 000000000000..c99c066915ec --- /dev/null +++ b/apps/desktop/src/preloadSnapshot.ts @@ -0,0 +1,18 @@ +/** + * 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; + + return { + read: (): T => { + snapshot ??= { value: readInitial() }; + return snapshot.value; + }, + 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/platform.ts b/apps/web/src/connection/platform.ts index 63c6563aea70..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; diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx new file mode 100644 index 000000000000..b4250cd84213 --- /dev/null +++ b/apps/web/src/connection/useDesktopLocalBootstraps.test.tsx @@ -0,0 +1,115 @@ +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(); +}); + +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 71642aecba46..8f9ce431cd5f 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.ts +++ b/apps/web/src/connection/useDesktopLocalBootstraps.ts @@ -3,14 +3,16 @@ import { useEffect, useState } from "react"; import { readDesktopSecondaryBootstraps } from "./desktopLocal"; -const DESKTOP_LOCAL_BOOTSTRAP_POLL_MS = 2_000; +// 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). 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, 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>( @@ -20,7 +22,9 @@ export function useDesktopLocalBootstraps(): ReadonlyArray { const read = () => setBootstraps(readDesktopSecondaryBootstraps()); read(); - const interval = setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); + const subscribe = window.desktopBridge?.onLocalEnvironmentBootstrapsChange; + if (subscribe) return subscribe(read); + const interval = setInterval(read, LEGACY_BRIDGE_POLL_MS); return () => clearInterval(interval); }, []); diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 042fe07b44c8..50002d8fef6b 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 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 bea353c2bef4..a84f34d29284 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1141,7 +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[]; + /** Called after the bootstraps change; read them with `getLocalEnvironmentBootstraps`. */ + onLocalEnvironmentBootstrapsChange?: (listener: () => void) => () => void; getLocalEnvironmentEnabled?: () => boolean; setLocalEnvironmentEnabled?: (enabled: boolean) => Promise; getLocalEnvironmentBearerToken: () => Promise;