819 lines · 31.0 KB · TypeScript
RawDownload
// Per-instance backend factory. Replaces the legacy singleton// `DesktopBackendManager` Context.Service: each call to// `makeBackendInstance(spec)` constructs an isolated backend lifecycle —// its own state Ref, mutex, restart loop, and active child process. The// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait// methods that operate on that single backend.//// The pool layer (`DesktopBackendPool.ts`) calls this factory once per// backend it wants to run. Today that's the Windows primary; follow-up// commits add a second call for the WSL instance.//// Singleton couplings that the legacy service held inline are now// parameterized via the spec://   - configResolve replaces the legacy `DesktopBackendConfiguration.resolve`//     so each instance can resolve its own start config — the primary wires//     `configuration.resolvePrimary`, the WSL orchestrator wires a//     `configuration.resolveWsl({ port, distro })` closure.//   - onReady / onShutdown drive UI side effects (window auto-open,//     readiness latch) only for instances that want them — the primary's//     spec passes the window's handleBackendReady/handleBackendNotReady,//     other pool instances pass nothing.//   - log writes go through a per-instance writer that the factory//     pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`,//     so each instance lands in its own rotating file. import * as Brand from "effect/Brand";import * as Cause from "effect/Cause";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";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 Result from "effect/Result";import * as Schedule from "effect/Schedule";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import { HttpClient } from "effect/unstable/http";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import {  DesktopBackendBootstrap,  type DesktopBackendBootstrap as DesktopBackendBootstrapValue,  PRIMARY_LOCAL_ENVIRONMENT_ID,} from "@t3tools/contracts";import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500);const MAX_RESTART_DELAY = Duration.seconds(10);// After this many consecutive fatal preflight failures, stop the silent// restart loop and surface the reason via onPreflightFailed. Transient// failures may instead provide their own larger retryLimit when they should// self-heal for a while but must not leave the app connecting forever.const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5;const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; export interface DesktopBackendStartConfig {  readonly executablePath: string;  readonly args: ReadonlyArray<string>;  readonly entryPath: string;  readonly cwd: string;  readonly env: Record<string, string | undefined>;  // When true the spawner merges the desktop process.env on top of `env`;  // when false `env` is passed verbatim. WSL mode opts out so a leaking  // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3.  readonly extendEnv: boolean;  readonly bootstrap: DesktopBackendBootstrapValue;  readonly bootstrapDelivery: DesktopBackendBootstrapDelivery;  readonly httpBaseUrl: URL;  readonly captureOutput: boolean;  readonly preflightFailure: Option.Option<PreflightFailure>;  // Present for a WSL run after the configured/default distro has been  // resolved to the concrete distro passed to wsl.exe.  readonly runningDistro?: string;} // A preflight failure records whether it is fatal. Transient failures (WSL// cold-starting, wslpath while the VM boots) keep retrying so the backend can// self-heal; fatal ones (no node, wrong version, missing build tools) are// surfaced via onPreflightFailed and stop the restart loop after// MAX_PREFLIGHT_FAILURE_ATTEMPTS.export interface PreflightFailure {  readonly reason: string;  readonly fatal: boolean;  readonly retryLimit?: number;} interface BackendProcessExit {  readonly code: Option.Option<number>;  readonly reason: string;  readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;} export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(  "BackendTimeoutError",  {    url: Schema.instanceOf(URL),  },) {  override get message() {    return `Timed out waiting for backend readiness at ${this.url.href}.`;  }} class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(  "BackendProcessBootstrapEncodeError",  {    entryPath: Schema.String,    cause: Schema.Defect(),  },) {  override get message() {    return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;  }} class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(  "BackendProcessSpawnError",  {    executablePath: Schema.String,    cause: Schema.Defect(),  },) {  override get message() {    return `Failed to spawn the desktop backend process at ${this.executablePath}.`;  }} type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; interface RunBackendProcessOptions extends DesktopBackendStartConfig {  readonly readinessTimeout?: Duration.Duration;  readonly onStarted?: (pid: number) => Effect.Effect<void>;  readonly onReady?: () => Effect.Effect<void>;  readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;  readonly onOutput?: (    streamName: BackendProcessOutputStream,    chunk: Uint8Array,  ) => Effect.Effect<void>;} export interface DesktopBackendSnapshot {  readonly desiredRunning: boolean;  readonly ready: boolean;  readonly activePid: Option.Option<number>;  readonly restartAttempt: number;  readonly restartScheduled: boolean;} // Opaque identifier for one backend process inside the pool. Today only// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually// these map 1:1 with environment ids on the frontend; keeping them// desktop-local for now avoids leaking the contracts dependency.export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">;export const BackendInstanceId = Brand.nominal<BackendInstanceId>(); export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId(  PRIMARY_LOCAL_ENVIRONMENT_ID,); // One pooled backend instance. Same lifecycle surface as the legacy// `DesktopBackendManagerShape`; the id and label give the pool registry// + UI something to route on.export interface DesktopBackendInstance {  readonly id: BackendInstanceId;  readonly label: Effect.Effect<string>;  readonly start: Effect.Effect<void>;  readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;  readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;  readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;  // 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  // rollback path.  readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect<boolean>;} // Spec describing one backend instance to spawn. The configResolve// effect is awaited each time the instance is (re)started so live// settings changes are picked up on the next start cycle. onReady and// onShutdown let the primary instance trigger UI side effects (window// open, global readiness flag) without coupling the factory to those// concerns; other instances pass them as undefined.export interface BackendInstanceSpec {  readonly id: BackendInstanceId;  readonly label: Effect.Effect<string>;  // configResolve can now fail with PlatformError because the  // bootstrap-token closure inside DesktopBackendConfiguration uses  // crypto.randomBytes (Effect 4 beta.73 migration).  readonly configResolve: Effect.Effect<DesktopBackendStartConfig, PlatformError.PlatformError>;  // Receives the *resolved* httpBaseUrl of the run that just became  // ready. The window service uses this to decide what URL to load  // (the WSL backend reports its distro IP, the Windows backend reports  // 127.0.0.1). Splitting this off from configResolve avoids races  // between "fired onReady" and "currentConfig already advanced".  readonly onReady?: (httpBaseUrl: URL) => Effect.Effect<void>;  readonly onShutdown?: () => Effect.Effect<void>;  // Fired once when a fatal or bounded preflight failure has exhausted its  // retries. Returns true when the callback changed configuration and the  // manager should resolve once more; false stops the failed instance.  readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect<boolean>;} interface ActiveBackendRun {  readonly id: number;  readonly scope: Scope.Closeable;  readonly fiber: Option.Option<Fiber.Fiber<void, never>>;  readonly pid: Option.Option<number>;} interface BackendManagerState {  readonly desiredRunning: boolean;  readonly ready: boolean;  readonly config: Option.Option<DesktopBackendStartConfig>;  readonly active: Option.Option<ActiveBackendRun>;  readonly restartAttempt: number;  // Consecutive bounded/fatal preflight failures, reset on a clean or  // unbounded-transient preflight. restartAttempt counts all restarts.  readonly preflightFailureAttempt: number;  readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;  readonly nextRunId: number;} const initialState: BackendManagerState = {  desiredRunning: false,  ready: false,  config: Option.none(),  active: Option.none(),  restartAttempt: 0,  preflightFailureAttempt: 0,  restartFiber: Option.none(),  nextRunId: 1,}; const activePid = (active: Option.Option<ActiveBackendRun>): Option.Option<number> =>  Option.flatMap(active, (run) => run.pid); const withActiveRun =  (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>  (state: BackendManagerState): BackendManagerState => ({    ...state,    active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)),  }); const calculateRestartDelay = (attempt: number): Duration.Duration =>  Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); const closeRun = (  run: ActiveBackendRun,  options?: { readonly timeout?: Duration.Duration },): Effect.Effect<void> => {  const waitForFiber = Option.match(run.fiber, {    onNone: () => Effect.void,    onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),  });  const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));   return (    options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close  ).pipe(Effect.ignore);}; const waitForHttpReady = (  baseUrl: URL,  timeout: Duration.Duration,): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => {  const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);  return waitForHttpReadyShared({    baseUrl: baseUrl.href,    path: BACKEND_READINESS_PATH,    timeoutMs: Duration.toMillis(timeout),    intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),    probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT),    makeError: () => new BackendTimeoutError({ url: readinessUrl }),  });}; function describeProcessExit(  result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,): BackendProcessExit {  if (Result.isSuccess(result)) {    return {      code: Option.some(result.success),      reason: `code=${result.success}`,      result,    };  }   return {    code: Option.none(),    reason: result.failure.message,    result,  };} function drainBackendOutput(  streamName: BackendProcessOutputStream,  stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,  onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>,): Effect.Effect<void> {  return stream.pipe(    Stream.runForEach((chunk) => onOutput(streamName, chunk)),    Effect.ignore,  );} const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const runBackendProcess = Effect.fn("runBackendProcess")(function* (  options: RunBackendProcessOptions,): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(    Effect.mapError(      (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }),    ),  );  const onOutput = options.onOutput ?? (() => Effect.void);  const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`));  const command = ChildProcess.make(options.executablePath, options.args, {    cwd: options.cwd,    env: options.env,    extendEnv: options.extendEnv,    // In Electron main, process.execPath points to the Electron binary.    // Run the child in Node mode so this backend process does not become a GUI app instance.    stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore",    stdout: options.captureOutput ? "pipe" : "inherit",    stderr: options.captureOutput ? "pipe" : "inherit",    killSignal: "SIGTERM",    forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,    // wsl.exe drops additional file descriptors when forwarding to the Linux    // side, so the WSL spawn path delivers the bootstrap envelope via stdin    // (`--bootstrap-fd 0`) instead.    ...(options.bootstrapDelivery === "fd3"      ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } }      : {}),  });   const handle = yield* spawner    .spawn(command)    .pipe(      Effect.mapError(        (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }),      ),    );   yield* options.onStarted?.(handle.pid) ?? Effect.void;  if (options.captureOutput) {    yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);    yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);  }  yield* waitForHttpReady(    options.httpBaseUrl,    options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,  ).pipe(    Effect.tap(() => options.onReady?.() ?? Effect.void),    Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),    Effect.forkScoped,  );   return describeProcessExit(yield* Effect.result(handle.exitCode));}); // Factory for one pooled backend instance. The returned instance owns// its own state Ref, mutex, restart loop, and active child process;// nothing is shared between instances created from separate// makeBackendInstance calls. The instance shuts down automatically when// the calling scope closes (typically the application scope).export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (  spec: BackendInstanceSpec,): Effect.fn.Return<  DesktopBackendInstance,  never,  | FileSystem.FileSystem  | ChildProcessSpawner.ChildProcessSpawner  | HttpClient.HttpClient  | DesktopObservability.DesktopBackendOutputLogFactory  | Scope.Scope> {  const parentScope = yield* Scope.Scope;  const fileSystem = yield* FileSystem.FileSystem;  const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;  const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const httpClient = yield* HttpClient.HttpClient;  const state = yield* Ref.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));   const snapshot = Ref.get(state).pipe(    Effect.map(      (current): DesktopBackendSnapshot => ({        desiredRunning: current.desiredRunning,        ready: current.ready,        activePid: activePid(current.active),        restartAttempt: current.restartAttempt,        restartScheduled: Option.isSome(current.restartFiber),      }),    ),  );  const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config));   const cancelRestart = Effect.gen(function* () {    const restartFiber = yield* Ref.modify(state, (current) => [      current.restartFiber,      {        ...current,        restartFiber: Option.none(),      },    ]);     yield* Option.match(restartFiber, {      onNone: () => Effect.void,      onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),    });  });   const start: Effect.Effect<void> = Effect.suspend(() =>    mutex.withPermits(1)(      Effect.gen(function* () {        const current = yield* Ref.get(state);        if (Option.isSome(current.active)) {          return;        }         if (current.ready) {          yield* spec.onShutdown?.() ?? Effect.void;          yield* Ref.update(state, (latest) =>            latest.ready ? { ...latest, ready: false } : latest,          );        }        const config = yield* spec.configResolve.pipe(          Effect.tapError((error) =>            logInstanceError("failed to generate desktop backend configuration", {              cause: error.message,            }),          ),          Effect.option,        );        if (Option.isNone(config)) {          return;        }        const entryExists = yield* fileSystem          .exists(config.value.entryPath)          .pipe(Effect.orElseSucceed(() => false));         const resetFatalPreflightCounter =          !current.desiredRunning && current.preflightFailureAttempt > 0;        yield* cancelRestart;        yield* Ref.update(state, (latest) => ({          ...latest,          desiredRunning: true,          ready: false,          config: Option.some(config.value),          preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt,        }));         const preflightFailure = config.value.preflightFailure;        if (Option.isSome(preflightFailure)) {          const { reason, fatal, retryLimit } = preflightFailure.value;          if (!fatal && retryLimit === undefined) {            // 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) =>              latest.preflightFailureAttempt === 0                ? latest                : { ...latest, preflightFailureAttempt: 0 },            );            yield* scheduleRestart(reason);            return;          }          const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS;          const attempt = yield* Ref.modify(state, (latest) => {            const next = latest.preflightFailureAttempt + 1;            return [next, { ...latest, preflightFailureAttempt: next }] as const;

Lines 1–500 of 819.