268 lines · 11.9 KB · TypeScript
RawDownload
// Orchestrator that keeps the WSL pool instance in sync with the user's// settings. `reconcile` is the single entry point — bootstrap calls it// once after the primary backend starts, and the wsl.ts IPC calls it// after persisting a `wslBackendEnabled` or `wslDistro` change. The// effect is idempotent and never fails: errors (WSL not available, port// allocation failed, register failed) get logged and reconcile returns// having left the pool in a consistent state (either the previous WSL// instance is still running, or none is).//// The instance id encodes the desired distro selection — `wsl:default`// when the user picked "track the WSL default" (settings.wslDistro is// null) and `wsl:<distro>` otherwise. Changing the distro setting// changes the id, so reconcile unregisters the old instance before// registering the new one. The label that the frontend env switcher// renders is derived from the same field.//// Port allocation: each WSL instance gets a freshly scanned port to// avoid colliding with the primary or with a previously-registered WSL// instance that's still tearing down. The scan only checks loopback// (127.0.0.1) since the WSL backend is loopback-only — the primary// owns LAN exposure when the user opts in. import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Semaphore from "effect/Semaphore"; import * as NetService from "@t3tools/shared/Net"; import * as DesktopObservability from "../app/DesktopObservability.ts";import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; // Exported so callers that parse pool ids (e.g. the pickFolder IPC// handler in ipc/methods/window.ts) reference the same prefix this// module produces. Keeping it inline in two places risks silent// divergence if one ever gets renamed.export const WSL_INSTANCE_ID_PREFIX = "wsl:";const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`;const MAX_TCP_PORT = 65_535; export class DesktopWslBackend extends Context.Service<  DesktopWslBackend,  {    // Bring the pool in line with the current persisted WSL settings.    // Idempotent. Never fails (errors are logged); callers can chain it    // after persisting settings without an error-handling dance.    readonly reconcile: Effect.Effect<void>;    // Reason the dual-mode WSL secondary last failed preflight (no node, wrong    // version, missing build tools), or None. Read by the getWslState IPC so    // Connections settings can show it inline. None in wsl-only mode (that path    // surfaces via a dialog + Windows fallback).    readonly lastPreflightError: Effect.Effect<Option.Option<string>>;  }>()("@t3tools/desktop/wsl/DesktopWslBackend") {} const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } =  DesktopObservability.makeComponentLogger("desktop-wsl-backend"); const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId =>  DesktopBackendPool.BackendInstanceId(    distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`,  ); const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean =>  id.startsWith(WSL_INSTANCE_ID_PREFIX); const buildLabel = (distro: string | null): string =>  distro === null ? "WSL (default distro)" : `WSL (${distro})`; // Loopback-only port scan starting one above the primary's port. The// WSL backend is reachable via 127.0.0.1 from Windows (wslhost// auto-forwards), so we only need to verify the IPv4 loopback can bind.const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* (  startPort: number,): Effect.fn.Return<number, NetService.NetError, NetService.NetService> {  const net = yield* NetService.NetService;  for (let port = startPort; port <= MAX_TCP_PORT; port += 1) {    if (yield* net.canListenOnHost(port, "127.0.0.1")) {      return port;    }  }  return yield* new NetService.NetError({    message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`,  });}); export const layer = Layer.effect(  DesktopWslBackend,  Effect.gen(function* () {    const pool = yield* DesktopBackendPool.DesktopBackendPool;    const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;    const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;    const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;    const appSettings = yield* DesktopAppSettings.DesktopAppSettings;    const net = yield* NetService.NetService;    // Serialize reconcile so the bootstrap fork and the IPC handlers    // (setWslBackendEnabled, setWslDistro) can't interleave. Without    // this, two reconciles could both observe "no WSL instance    // registered" between their pool reads and both call startNew    // with different distros, leaving the loser stranded.    const reconcileMutex = yield* Semaphore.make(1);     // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced    // inline in Connections settings. The primary's failure is handled by the    // pool (dialog + Windows fallback) instead; here the app stays usable on    // Windows, so we record the reason rather than interrupting. Cleared on any    // reconcile state change so it reflects the current attempt.    const preflightErrorRef = yield* Ref.make(Option.none<string>());     const findExistingWslInstance = pool.list.pipe(      Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))),      Effect.map(Option.fromNullishOr),    );     const stopExisting = (id: DesktopBackendPool.BackendInstanceId) =>      pool.unregister(id).pipe(        Effect.catchTags({          DesktopBackendPoolCannotUnregisterPrimaryError: (cause) =>            // Should never happen — wsl: ids are not the primary id — but            // log loudly if the logic ever drifts.            logWslBackendWarning("refusing to unregister primary as wsl instance", {              id,              error: cause.message,            }),        }),      );     const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: {      readonly distro: string | null;    }) {      const primaryConfig = yield* serverExposure.backendConfig;      const port = yield* scanForWslPort(primaryConfig.port + 1).pipe(        Effect.provideService(NetService.NetService, net),        Effect.map((value) => Option.some(value)),        Effect.catch((error) =>          logWslBackendWarning("could not allocate port for WSL backend", {            error: error.message,          }).pipe(Effect.as(Option.none<number>())),        ),      );       if (Option.isNone(port)) {        return;      }      const allocatedPort = port.value;       const targetId = resolveTargetInstanceId(input.distro);      yield* logWslBackendInfo("registering WSL backend with pool", {        id: targetId,        port: allocatedPort,        distro: input.distro ?? null,      });       const instance = yield* pool        .register({          id: targetId,          label: Effect.succeed(buildLabel(input.distro)),          configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }),          // Dual-mode secondary: record a fatal preflight failure so Connections          // settings can show why the WSL backend never appeared. No dialog or          // fallback — Windows is the primary and keeps working.          onPreflightFailed: (failure) =>            Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)),          onReady: () => Ref.set(preflightErrorRef, Option.none()),        })        .pipe(          Effect.map((registered) => Option.some(registered)),          Effect.catch((error) =>            logWslBackendWarning("WSL backend already registered, skipping start", {              id: targetId,              error: error.message,            }).pipe(Effect.as(Option.none<DesktopBackendPool.DesktopBackendInstance>())),          ),        );       yield* Option.match(instance, {        onNone: () => Effect.void,        onSome: (registered) => registered.start,      });    });     const reconcileBody = Effect.gen(function* () {      const settings = yield* appSettings.get;      const available = yield* wslEnvironment.isAvailable;      const existing = yield* findExistingWslInstance;      const existingId = Option.map(existing, (instance) => instance.id);       // In wsl-only mode the pool's primary IS the WSL backend (see      // DesktopBackendConfiguration.resolvePrimary), so the      // orchestrator skips registering a parallel "wsl:<distro>"      // secondary. Without this skip we'd spin up two WSL processes      // on the same distro for users who explicitly asked for one.      const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly;      const targetId = shouldRun        ? Option.some(resolveTargetInstanceId(settings.wslDistro))        : Option.none<DesktopBackendPool.BackendInstanceId>();       // No-op if the desired state already matches what's registered.      if (Option.isNone(targetId) && Option.isNone(existingId)) {        return;      }      if (        Option.isSome(targetId) &&        Option.isSome(existing) &&        targetId.value === existing.value.id      ) {        const existingInstance = existing.value;        const snapshot = yield* existingInstance.snapshot;        const isIdle =          !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled;        if (isIdle) {          yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id });          yield* Ref.set(preflightErrorRef, Option.none());          yield* existingInstance.start;        }        return;      }       // A real state change is happening (start, stop, or distro swap). Clear      // any stale secondary preflight error so it reflects this fresh attempt;      // onPreflightFailed re-sets it only if the new secondary exhausts retries.      yield* Ref.set(preflightErrorRef, Option.none());       if (Option.isSome(existingId)) {        yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value });        yield* stopExisting(existingId.value);      }       if (Option.isSome(targetId)) {        // Pre-warm the WSL VM before registering so the readiness probe        // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates        // distro=null (uses the WSL default) and is bounded by its own        // timeout, so it's safe to await unconditionally here.        yield* wslEnvironment.preWarm(settings.wslDistro);        yield* startNew({ distro: settings.wslDistro });      }    });     // Top-level safety net. Every internal step today already catches    // its own failures (port allocation, register, preWarm), so the    // inferred error type is `never` and this catch is a no-op in    // steady state. It's here to enforce the file-header contract    // ("reconcile never fails; errors are logged") if a future change    // introduces an unhandled failure path — otherwise IPC callers    // like setWslBackendEnabled would surface it to the renderer as    // an opaque error.    const reconcile = reconcileMutex      .withPermits(1)(reconcileBody)      .pipe(        Effect.catchCause((cause) =>          logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }),        ),        Effect.withSpan("desktop.wslBackend.reconcile"),      );     return DesktopWslBackend.of({      reconcile,      lastPreflightError: Ref.get(preflightErrorRef),    });  }),);