474 lines · 21.9 KB · TypeScript
RawDownload
// Pool registry for multiple backend processes. This file is the entry// point for the concurrent-Windows+WSL-backend feature; see the design// notes below before extending it.//// Current state://   - `DesktopBackendManager.ts` exposes a per-instance factory//     (`makeBackendInstance(spec)`); the pool calls it once for the//     Windows primary at startup, and `DesktopWslBackend.reconcile`//     calls it through `pool.register` to bring up the WSL instance//     when the user enables it.//   - The primary spec wires `configResolve` to//     `DesktopBackendConfiguration.resolvePrimary` and the//     `onReady`/`onShutdown` callbacks to the window service. WSL//     instances wire `configResolve: configuration.resolveWsl(...)`//     and skip onReady/onShutdown — the window only follows the primary.//   - The pool exposes `register(spec)` and `unregister(id)`. Each//     registered instance gets its own child scope, so unregister can//     stop it cleanly without tearing down the pool. The primary's id//     refuses unregister.//   - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`.//     The legacy `wslMode: "local" | "wsl"` swap setting is migrated on//     load. IPC surface is `setWslBackendEnabled(boolean)` +//     `setWslDistro(string | null)`; both persist and then call the//     orchestrator's reconcile. No swap, no rollback, primary stays up.//   - `getLocalEnvironmentBootstraps()` (plural) returns one entry per//     pool instance currently registered with bootstrap info. The//     primary keeps the "primary" id; WSL instances are "wsl:default"//     or "wsl:<distro>".//   - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting//     it gives the Windows picker — what every existing caller gets,//     and what non-WSL users see. WSL targets route to the wsl helpers.//   - Web settings UX: a plain toggle for "WSL backend" plus a distro//     picker that shows up when the toggle is on. Default-off, so//     users who never opted in see the same surface as before.//// Renderer-side wiring (apps/web/src/environments/local/)://   - reconcileLocalSecondaryEnvironments() runs at app boot and after//     WSL settings changes. It reads getLocalEnvironmentBootstraps(),//     skips the primary (which the existing primary/ runtime owns),//     and for every other entry POSTs the shared bootstrap token to//     /api/auth/bootstrap/bearer on that backend's URL, fetches the//     descriptor, builds a SavedEnvironmentRecord marked desktopLocal,//     writes the bearer to the secret store, and opens a connection//     through the same saved-env path remote envs use.//   - The desktopLocal marker filters records out of saved-env//     persistence, so toggling WSL off or switching distros doesn't//     pollute the user's settings file. The sidebar, CommandPalette,//     env switcher, and project-id routing all read the saved-env//     registry, so the WSL backend shows up there without any//     per-surface changes.//// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true,// wslDistro="Ubuntu")://   - Two backends listening on distinct loopback ports//     (server.log: 13773 primary, 13774 wsl).//   - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log.//   - Distinct environment ids reported by each backend's//     /.well-known/t3/environment (Windows vs Linux platform).//   - Renderer completes the bearer-token bootstrap against the WSL//     backend (POST /api/auth/bootstrap/bearer 200), obtains a//     ws-token (POST /api/auth/ws-token 200), and holds an//     ESTABLISHED WebSocket connection to both ports (netstat).//// Migration history (commits)://   1. Reshape `DesktopBackendManager` into an instance factory and route//      consumers through the pool. Pool held a single instance. (a8fc7845)//   2. Drop `DesktopState.backendReady`. The window owns its own//      readiness latch via onReady / onShutdown callbacks. (425c7d0b)//   3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed)//   4. Add register/unregister to the pool. (a0eaf560)//   5. Wire WSL through the pool: settings rename, BackendConfiguration//      split, DesktopWslBackend orchestrator, new IPC, web compat.//      (b1622191 + 31ce3add + 627c80cb)//   6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041)//   7. pickFolder takes optional targetEnvironmentId. (5d80468d)//   8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea)//   9. Register WSL backend as desktop-local saved env via//      reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd)//   10. CommandPalette enables file-manager picker for desktop-local//       envs, routes pickFolder by env id. (38e8477a) import * as Context from "effect/Context";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";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 FileSystem from "effect/FileSystem";import { HttpClient } from "effect/unstable/http";import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";import * as DesktopBackendManager from "./DesktopBackendManager.ts";import * as DesktopObservability from "../app/DesktopObservability.ts";import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";import * as DesktopWindow from "../window/DesktopWindow.ts";import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";import * as ElectronDialog from "../electron/ElectronDialog.ts"; const { logWarning: logBackendPoolWarning } =  DesktopObservability.makeComponentLogger("desktop-backend-pool"); export type BackendInstanceId = DesktopBackendManager.BackendInstanceId;export const BackendInstanceId = DesktopBackendManager.BackendInstanceId;export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID;export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance;export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; // Caller tried to register an id that's already in the pool. The pool// refuses overwrites so two independent orchestrators racing on the// same id surface as a typed failure instead of one silently winning.export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedErrorClass<DesktopBackendPoolInstanceAlreadyRegisteredError>()(  "DesktopBackendPoolInstanceAlreadyRegisteredError",  {    id: Schema.String,  },) {  override get message() {    return `Backend instance "${this.id}" is already registered in the pool.`;  }} // Primary instance is registered for the pool's lifetime. Unregister is// a no-op for it today (no real callers), but if someone wires it up// later it's a clear bug rather than something to "handle".export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedErrorClass<DesktopBackendPoolCannotUnregisterPrimaryError>()(  "DesktopBackendPoolCannotUnregisterPrimaryError",  {},) {  override get message() {    return "Refusing to unregister the primary backend from the pool.";  }} export class DesktopBackendPool extends Context.Service<  DesktopBackendPool,  {    // Look up a registered instance. None when no backend with that id is    // currently registered (e.g. WSL backend disabled).    readonly get: (id: BackendInstanceId) => Effect.Effect<Option.Option<DesktopBackendInstance>>;    // Snapshot of all currently-registered instances. Order is unspecified;    // callers that need a canonical "primary first" view should sort by id.    readonly list: Effect.Effect<readonly DesktopBackendInstance[]>;    // Convenience accessor for the always-registered primary instance.    // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but    // 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<DesktopBackendInstance>;    // 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    // returned instance has not been started — callers decide when to    // start it (and can call start more than once if a retry-after-failure    // story makes sense for them).    readonly register: (      spec: BackendInstanceSpec,    ) => Effect.Effect<DesktopBackendInstance, DesktopBackendPoolInstanceAlreadyRegisteredError>;    // Stop the named instance and remove it from the registry. Closing the    // instance's scope triggers its auto-stop finalizer; the registry is    // updated atomically with the scope close so subsequent get(id) calls    // observe the unregister before the underlying child process has fully    // exited.    readonly unregister: (      id: BackendInstanceId,    ) => Effect.Effect<void, DesktopBackendPoolCannotUnregisterPrimaryError>;  }>()("@t3tools/desktop/backend/DesktopBackendPool") {} // Services required by makeBackendInstance — exported so caller// orchestrators that build their own specs can confirm the layer graph// satisfies them at compile time.export type BackendInstanceFactoryRequirements =  | FileSystem.FileSystem  | ChildProcessSpawner.ChildProcessSpawner  | HttpClient.HttpClient  | DesktopObservability.DesktopBackendOutputLogFactory  | DesktopTelemetryPublisher.DesktopTelemetryPublisher  | DesktopWslEnvironment.DesktopWslEnvironment; interface ActiveRegisteredInstance {  readonly _tag: "Active";  readonly instance: DesktopBackendInstance;  // None for the primary (which lives in the pool's own layer scope and  // is never unregistered); Some for instances added via register, whose  // scope unregister closes to stop them.  readonly scope: Option.Option<Scope.Closeable>;} interface ClosingRegisteredInstance {  readonly _tag: "Closing";  readonly done: Deferred.Deferred<void>;} type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance; type RegisterAction =  | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance }  | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }; type UnregisterAction =  | { readonly _tag: "Absent" }  | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }  | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; export const layer = Layer.effect(  DesktopBackendPool,  Effect.gen(function* () {    const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;    const desktopWindow = yield* DesktopWindow.DesktopWindow;    const electronDialog = yield* ElectronDialog.ElectronDialog;    const appSettings = yield* DesktopAppSettings.DesktopAppSettings;    // Anchor the pool's lifetime to its layer scope so registered    // instance scopes can be forked off it. Without this, instance    // scopes are orphaned: they only close via explicit unregister()    // calls, so on app shutdown the WSL backend child process gets    // hard-killed by the OS instead of receiving the graceful    // SIGTERM + grace period the instance's stop finalizer would    // otherwise run.    const layerScope = yield* Scope.Scope;    // Capture the services needed to build any future instance from the    // pool's layer scope. register() runs `makeBackendInstance` against    // a fresh child scope but reuses these services so the instance gets    // the same FileSystem, spawner, HTTP client and log factory the    // primary instance uses.    const factoryContext = yield* Effect.context<BackendInstanceFactoryRequirements>();     // A WSL preflight failure on the primary only happens in wsl-only mode.    // Fatal configuration failures persist the Windows fallback. Bounded    // transport failures use an in-memory fallback for this launch so the app    // opens without overwriting the user's WSL preference.    const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")(      function* (failure: DesktopBackendManager.PreflightFailure) {        const { reason, fatal } = failure;        if (!fatal) {          yield* logBackendPoolWarning(            "primary WSL preflight retry window exhausted; using Windows for this launch",            { reason },          );          yield* electronDialog.showErrorBox(            "WSL backend is still unavailable",            `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`,          );          yield* appSettings.applyWslWindowsFallbackInMemory;          return true;        }         yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", {          reason,        });        yield* electronDialog.showErrorBox(          "WSL backend couldn't start",          `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`,        );        // Fully disable the WSL backend — both flags, matching the "Switch to        // Windows" recovery path — so the manager's next restart re-resolves the        // primary as Windows and reconcile won't register a secondary WSL backend        // against the same broken setup. Clearing wslBackendEnabled alone would        // leave a stale wslOnly:true that silently re-traps the user in wsl-only        // mode the next time they enable WSL. If the persisted write fails, keep        // this process recoverable by applying the fallback to in-memory settings.        yield* appSettings.applyWslWindowsFallback.pipe(          Effect.catch((error) =>            logBackendPoolWarning(              "failed to persist Windows fallback after WSL preflight failure",              {                error: error.message,              },            ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)),          ),        );        return true;      },    );     const primary = yield* DesktopBackendManager.makeBackendInstance({      id: DesktopBackendManager.PRIMARY_INSTANCE_ID,      // Keep this lazy. The pool layer is initialized before startup loads      // persisted desktop settings, so resolving the primary label here would      // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only      // primaries as Windows.      label: configuration.resolvePrimaryLabel,      configResolve: configuration.resolvePrimary,      // Window creation errors propagating out of handleBackendReady must      // not block the readiness callback (that would prevent restartAttempt      // from being reset), so we absorb them here. The window service only      // logs on success, so log the failure here before swallowing it —      // otherwise a post-readiness window-open failure vanishes silently and      // is near-impossible to diagnose in production.      onReady: (httpBaseUrl) =>        desktopWindow.handleBackendReady(httpBaseUrl).pipe(          Effect.catch((error) =>            logBackendPoolWarning("failed to open main window after backend readiness", {              error: error.message,            }),          ),        ),      onShutdown: () => desktopWindow.handleBackendNotReady,      onPreflightFailed: handlePrimaryPreflightFailure,    });     const instancesRef = yield* SynchronizedRef.make<      ReadonlyMap<BackendInstanceId, RegisteredInstance>    >(      new Map([        [          DesktopBackendManager.PRIMARY_INSTANCE_ID,          { _tag: "Active", instance: primary, scope: Option.none() },        ],      ]),    );     const register: DesktopBackendPool["Service"]["register"] = (spec) =>      Effect.suspend(() =>        SynchronizedRef.modifyEffect(          instancesRef,          (            current,          ): Effect.Effect<            readonly [RegisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>],            DesktopBackendPoolInstanceAlreadyRegisteredError          > => {            const existing = current.get(spec.id);            if (existing?._tag === "Active") {              return Effect.fail(                new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }),              );            }            if (existing?._tag === "Closing") {              return Effect.succeed([                { _tag: "Wait", done: existing.done } as const,                current,              ] as const);            }            return Effect.gen(function* () {              // Provide the captured factory services first, then the child scope              // last so instance finalizers are owned by the unregisterable scope.              const instanceScope = yield* Scope.fork(layerScope, "sequential");              const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe(                Effect.provide(factoryContext),                Scope.provide(instanceScope),              );              const next = new Map(current);              next.set(spec.id, {                _tag: "Active",                instance,                scope: Option.some(instanceScope),              });              return [                { _tag: "Registered", instance } as const,                next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,              ] as const;            });          },        ).pipe(          Effect.flatMap((result) =>            result._tag === "Registered"              ? Effect.succeed(result.instance)              : Deferred.await(result.done).pipe(Effect.andThen(register(spec))),          ),        ),      );     const unregister: DesktopBackendPool["Service"]["unregister"] = (id) =>      Effect.gen(function* () {        if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) {          return yield* new DesktopBackendPoolCannotUnregisterPrimaryError();        }        const done = yield* Deferred.make<void>();        const action = yield* SynchronizedRef.modifyEffect(          instancesRef,          (            current,          ): Effect.Effect<            readonly [UnregisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>]          > => {            const entry = current.get(id);            if (entry === undefined) {              return Effect.succeed([{ _tag: "Absent" } as const, current] as const);            }            if (entry._tag === "Closing") {              return Effect.succeed([                { _tag: "Wait", done: entry.done } as const,                current,              ] as const);            }            const next = new Map(current);            next.set(id, { _tag: "Closing", done });            return Effect.succeed([              { _tag: "Close", entry } as const,              next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,            ] as const);          },        );         if (action._tag === "Absent") return;        if (action._tag === "Wait") {          yield* Deferred.await(action.done);          return;        }         const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => {          const closing = current.get(id);          if (closing?._tag !== "Closing" || closing.done !== done) {            return Effect.succeed([undefined, current] as const);          }          const next = new Map(current);          next.delete(id);          return Effect.succeed([            undefined,            next as ReadonlyMap<BackendInstanceId, RegisteredInstance>,          ] as const);        }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid);        yield* Option.match(action.entry.scope, {          onNone: () => Effect.void,          onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore),        }).pipe(Effect.ensuring(finish));      });     return DesktopBackendPool.of({      get: (id) =>        SynchronizedRef.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(        Effect.map((instances) =>          Array.from(instances.values()).flatMap((entry) =>            entry._tag === "Active" ? [entry.instance] : [],          ),        ),      ),      primary: Effect.succeed(primary),      register,      unregister,    });  }),); // Test layer for unit tests that want to assert against a known pool// composition without standing up the full manager. Each provided// instance is registered under its own id; the first one is also// surfaced as `primary` so callers can stub a single-instance pool.// `register` and `unregister` are stubbed to die so tests that// accidentally exercise pool registration fail loudly instead of// silently noop'ing.export const layerTest = (  instances: readonly DesktopBackendInstance[],): Layer.Layer<DesktopBackendPool> =>  Layer.effect(    DesktopBackendPool,    Effect.gen(function* () {      if (instances.length === 0) {        return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance");      }      const byId = new Map<BackendInstanceId, DesktopBackendInstance>(        instances.map((instance) => [instance.id, instance] as const),      );      const primary = instances[0]!;      return DesktopBackendPool.of({        get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))),        list: Effect.succeed(Array.from(byId.values())),        primary: Effect.succeed(primary),        register: () => Effect.die("DesktopBackendPool.layerTest does not support register"),        unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"),      });    }),  );