1269 lines · 51.4 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Duration from "effect/Duration";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 Path from "effect/Path";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1);const LIST_TIMEOUT = Duration.seconds(8);const PRE_WARM_TIMEOUT = Duration.seconds(10);const WSLPATH_TIMEOUT = Duration.seconds(10);const PROBE_TIMEOUT = Duration.seconds(10);const TOOLCHAIN_TIMEOUT = Duration.seconds(10);const BUILD_TIMEOUT = Duration.minutes(5);const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2);const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30);const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15);const USER_HOME_TIMEOUT = Duration.seconds(5);const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;const BUILD_TRANSPORT_RETRY_LIMIT = 2; export interface EnsureWslNodePtyOptions {  readonly allowBuild?: boolean;  readonly nodeEngineRange?: string | null;} // The packaged WSL runtime archive plus the SHA-256 identity the build recorded// for it. The cache key derives from the same digest, and installation verifies// the bytes before promoting the extracted tree.export interface WslRuntimeArchive {  readonly windowsPath: string;  readonly runtimeId: string;  readonly sha256: string;} export type PrepareWslRuntimeResult =  | {      readonly ok: true;      readonly linuxAppRoot: string;    }  | {      readonly ok: false;      readonly reason: string;    }; export type EnsureWslNodePtyResult =  | {      readonly ok: true;      readonly nodePath: string;      readonly resolvedPath: string;    }  | {      readonly ok: false;      readonly reason: string;      readonly fatal: boolean;      readonly retryLimit?: number;    }; export class DesktopWslDistroListError extends Schema.TaggedErrorClass<DesktopWslDistroListError>()(  "DesktopWslDistroListError",  { reason: Schema.String },) {  override get message(): string {    return this.reason;  }} const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service<  DesktopWslEnvironment,  {    readonly isAvailable: Effect.Effect<boolean>;    // Best-effort enumeration for renderer UX. Backend health checks must use    // probeDistros so a transient command failure is not mistaken for a    // successful empty installation.    readonly listDistros: Effect.Effect<readonly WslDistro[]>;    readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>;    readonly preWarm: (distro: string | null) => Effect.Effect<void>;    readonly windowsToWslPath: (      distro: string | null,      windowsPath: string,    ) => Effect.Effect<Option.Option<string>>;    // Resolves the user's Linux home dir inside the chosen distro (e.g.    // "/home/josh"). Used by the folder picker to expand `~` correctly.    readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>;    // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter    // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's    // httpBaseUrl so the renderer can reach it without relying on wslhost's    // localhost→WSL automatic forwarding, which is flaky in practice    // (the backend can be listening for 30+ seconds before wslhost starts    // forwarding 127.0.0.1:port to WSL-side localhost).    readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>;    readonly prepareRuntime: (      distro: string | null,      archive: WslRuntimeArchive,    ) => Effect.Effect<PrepareWslRuntimeResult>;    readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;    // Marks a staged runtime as unusable so the next launch reinstalls it.    readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;    readonly ensureNodePty: (      distro: string | null,      linuxAppRoot: string,      options?: EnsureWslNodePtyOptions,    ) => Effect.Effect<EnsureWslNodePtyResult>;  }>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} const buildDistroArgs = (distro: string | null): ReadonlyArray<string> =>  distro ? ["-d", distro] : []; const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => {  let totalLength = 0;  for (const arr of arrays) totalLength += arr.byteLength;  const out = new Uint8Array(totalLength);  let offset = 0;  for (const arr of arrays) {    out.set(arr, offset);    offset += arr.byteLength;  }  return out;}; const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); interface ShellResult {  readonly exitCode: number;  readonly stdout: string;  readonly stderr: string;  readonly transportFailure: "timeout" | "spawn" | "process" | null;} const TIMEOUT_RESULT: ShellResult = {  exitCode: 124,  stdout: "",  stderr: "\n[timeout]",  transportFailure: "timeout",}; export const formatWslShellTransportFailureReason = (  failure: ShellResult["transportFailure"],): string | null => {  switch (failure) {    case "timeout":      return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy.";    case "spawn":      return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible.";    case "process":      return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy.";    case null:      return null;  }}; // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node// the same way. Passing the engine range lets the resolver fall through to// version managers like nvm when a system node exists but is too old.export const buildWslNodeEnvPreamble = (  nodeEngineRange?: string | null,): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })}ensure_remote_node_path || true`; // wsl.exe re-escapes args before forwarding them to the Linux side, which// mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to// avoid passing it on the command line at all.const runWslShell = (  distro: string | null,  bashScript: string,  timeout: Duration.Duration,  options: {    readonly nodeEngineRange?: string | null;    readonly resolveNode?: boolean;  } = {},): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {  const spawner = ChildProcessSpawner.ChildProcessSpawner;  // Node probes use a login bash so profile-managed PATH entries and supported  // version managers are available. Runtime installation needs only POSIX tools,  // so it skips profile loading and runs sh directly.  const resolveNode = options.resolveNode !== false;  const command = ChildProcess.make(    "wsl.exe",    resolveNode      ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]      : [...buildDistroArgs(distro), "--exec", "sh", "-s"],    {      stdin: Stream.encodeText(        Stream.make(          resolveNode            ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`            : bashScript,        ),      ),      stdout: "pipe",      stderr: "pipe",      killSignal: "SIGTERM",      forceKillAfter: PROCESS_TERMINATE_GRACE,    },  );   return Effect.scoped(    Effect.gen(function* () {      const spawnerService = yield* spawner;      const spawnResult = yield* spawnerService.spawn(command).pipe(        Effect.match({          onFailure: (error) => ({ _tag: "Failure", error }) as const,          onSuccess: (handle) => ({ _tag: "Success", handle }) as const,        }),      );      if (spawnResult._tag === "Failure") {        return {          exitCode: 127,          stdout: "",          stderr: `\n${spawnResult.error.message}`,          transportFailure: "spawn",        } satisfies ShellResult;      }      const handle = spawnResult.handle;      // Drain stdout and stderr concurrently so neither pipe buffer can fill      // and stall the child (node-gyp rebuild emits large output on both).      const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all(        [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode],        { concurrency: "unbounded" },      );      return {        exitCode: exitCode as unknown as number,        stdout: decodeUtf8(concatChunks(stdoutBytes)),        stderr: decodeUtf8(concatChunks(stderrBytes)),        transportFailure: null,      } satisfies ShellResult;    }),  ).pipe(    Effect.timeoutOption(timeout),    Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)),    Effect.catch((error) =>      Effect.succeed<ShellResult>({        exitCode: 127,        stdout: "",        stderr: `\n${error.message}`,        transportFailure: "process",      }),    ),  );}; const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; // Holds the sha256 of the runtime's server entry, written when the install// promotes a verified tree. Presence alone only says an install once finished// here; the digest is what lets a later launch prove the entry still is what// that install wrote.const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready";const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected";const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; export const sanitizeWslRuntimeId = (value: string): string =>  value.replace(/[^A-Za-z0-9._-]/g, "_"); // `archiveSha256` is the digest the build recorded alongside the archive. The// install verifies the bytes before extracting, so an archive can never be// promoted under an identity that does not describe it.export const buildWslRuntimeInstallScript = (  linuxArchivePath: string,  runtimeId: string,  archiveSha256: string,): string => {  const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);  return [    "set -eu",    'runtime_parent="$HOME/.t3/wsl-runtime"',    `runtime_root="$runtime_parent/${safeRuntimeId}"`,    `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,    // The native payload is the part of the tree the WSL backend actually    // dlopens, and the only part a user can plausibly break by hand. Checking    // node-pty's package.json alone let a runtime whose pty.node had gone    // missing stay cache-ready forever: every launch reused it and then failed    // the native probe, with no reinstall and no fallback. Match on the glob    // rather than a mapped `uname -m` so this stays a presence check; the probe    // is what decides whether the binary is the right arch and loadable.    "node_pty_payload_present() {",    '  for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',    '    [ -f "$candidate" ] || continue',    '    [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue',    "    return 0",    "  done",    "  return 1",    "}",    // Hashing the server entry is the only check that can tell a working cache    // from one whose bin.mjs was truncated or half-written: the file is still    // there, the native probe still passes, and launch then picks a server that    // exits before it can become ready, on every restart. Hashing the ~7MB    // entry measures in single-digit milliseconds inside the distro, once per    // launch, against a cold reinstall of a few hundred megabytes.    "runtime_server_entry_digest() {",    `  sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,    "}",    "runtime_is_ready() {",    '  [ -f "$ready_marker" ] &&',    '    [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&',    '    [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&',    '    node_pty_payload_present "$runtime_root" &&',    // An empty or unreadable marker is a miss, not a pass: that is what a    // runtime installed before the marker carried a digest looks like, and one    // reinstall is the cheapest way to make it verifiable from then on.    `    recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`,    '    [ -n "$recorded_entry_digest" ] &&',    '    [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',    "}",    'mkdir -p "$runtime_parent"',    `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`,    "trap 'exit 1' HUP INT TERM",    'exec 9> "$runtime_lock"',    "flock -x 9",    "if runtime_is_ready; then",    `  touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,    `  printf 'runtimeRoot:%s\\n' "$runtime_root"`,    "  exit 0",    "fi",    // Hash only on a cache miss: a warm launch already exited above, and a cold    // install is about to read the whole archive through tar anyway. `set -eu`    // turns a distro without sha256sum into an install failure, which falls back    // to the mounted server tree rather than trusting unverified bytes.    `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`,    `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`,    `  printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`,    "  exit 1",    "fi",    // A backend can still be running out of an unready tree: the probe revokes    // the ready marker without stopping the process it just failed for, and    // invalidation deliberately leaves the tree in place for exactly that    // reason. Deleting it here unlinks node_modules from under a live backend,    // which then breaks the moment it lazily loads anything it had not already    // read. Move it aside either way, but only delete it now when nothing is    // running from it; otherwise hand it to the pruner's scratch sweep, which    // is what that delay is for. A process's cmdline keeps the pre-rename path,    // so this has to be asked before the move, not after. This script arrives    // on stdin, so it cannot match itself.    "runtime_in_use() {",    // No /proc means no way to tell, and guessing wrong costs a live backend    // its runtime. Keeping the tree only costs disk until the sweep runs.    "  [ -d /proc/1 ] || return 0",    '  grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',    "}",    'if [ -e "$runtime_root" ]; then',    '  if runtime_in_use "$runtime_root"; then',    "    runtime_root_in_use=1",    "  else",    "    runtime_root_in_use=0",    "  fi",    `  runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`,    '  rmdir "$runtime_stale"',    '  if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then',    '    if [ "$runtime_root_in_use" = 1 ]; then',    // Renaming keeps the directory's old mtime, so restart the cleanup clock.    '      touch "$runtime_stale"',    "    else",    '      rm -rf "$runtime_stale"',    "    fi",    "  fi",    "fi",    `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,    'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',    "trap cleanup_runtime_install EXIT",    `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`,    'test -f "$runtime_tmp/apps/server/dist/bin.mjs"',    'test -f "$runtime_tmp/node_modules/node-pty/package.json"',     // Never write the ready marker over a tree that is missing the native    // payload. Failing here drops out to the mounted-tree fallback, which is    // recoverable; promoting it would mark the defect ready and cache it.    'if ! node_pty_payload_present "$runtime_tmp"; then',    "  printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2",    "  exit 1",    "fi",    // The archive's bytes were verified against archiveSha256 above, so the    // digest recorded here describes content this install proved. Every later    // warm reuse checks the entry against it.    'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',    'if [ -z "$installed_entry_digest" ]; then',    "  printf 'Could not hash the WSL runtime server entry\\n' >&2",    "  exit 1",    "fi",    `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`,    'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then',    "  :",    "elif runtime_is_ready; then",    '  rm -rf "$runtime_tmp"',    "else",    `  printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`,    "  exit 1",    "fi",    `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,    `printf 'runtimeRoot:%s\\n' "$runtime_root"`,  ].join("\n");}; // An interrupted install leaves a dot-prefixed scratch directory behind. A cold// install extracts a few hundred MB inside the distro, so two hours is far past// any live install while still bounding how long an orphan survives.const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120; export const buildWslRuntimePruneScript = (runtimeId: string): string => {  const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);  return [    "set -eu",    'runtime_parent="$HOME/.t3/wsl-runtime"',    `current_runtime="$runtime_parent/${safeRuntimeId}"`,    '[ -d "$runtime_parent" ] || exit 0',    // Serialize the whole retention decision so two backends cannot select    // different "previous" caches and delete around one another.    'prune_lock="$runtime_parent/.prune.lock"',    'exec 8> "$prune_lock"',    "flock -x 8",    // Without a way to see the distro's processes we cannot tell which caches    // are load-bearing, and the retention rules below are not safe on their own.    "[ -d /proc/1 ] || exit 0",    "runtime_in_use() {",    '  grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',    "}",    'previous_runtime=""',    'for candidate in "$runtime_parent"/sha256-*; do',    '  [ -d "$candidate" ] || continue',    '  [ "$candidate" != "$current_runtime" ] || continue',    `  [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`,    '  if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then',    '    previous_runtime="$candidate"',    "  fi",    "done",    // Only this desktop-owned prefix is eligible. Markerless roots are broken    // caches left by invalidation and must not become permanent disk leaks.    'for candidate in "$runtime_parent"/sha256-*; do',    '  [ -d "$candidate" ] || continue',    '  [ "$candidate" != "$current_runtime" ] || continue',    '  [ "$candidate" != "$previous_runtime" ] || continue',    '  ! runtime_in_use "$candidate" || continue',    "  candidate_name=${candidate##*/}",    '  candidate_lock="$runtime_parent/.${candidate_name}.install.lock"',    '  exec 9> "$candidate_lock"',    // A held lock means another launch is installing or repairing this cache.    // Skip instead of waiting or deleting underneath it.    "  flock -n 9 || continue",    `  selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`,    `  if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`,    "    flock -u 9",    "    continue",    "  fi",    '  rm -rf -- "$candidate"',    "  flock -u 9",    "done",    // Interrupted installs use dot-prefixed names under this dedicated parent.    'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',    '  [ -d "$scratch" ] || continue',    `  find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`,    '  rm -rf -- "$scratch"',    "done",  ].join("\n");}; // Drops the ready marker so the next launch reinstalls the runtime from the// archive. Readiness is a presence check by design, so a cached tree whose// native payload is present but unloadable (truncated pty.node, a distro whose// glibc the binary needs and the tree was copied from another machine) stays// ready forever and fails the probe on every launch. Only the probe can see// that, so the probe is what revokes the marker. The tree itself is left in// place: the install script moves an unready root aside before extracting.export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {  const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);  return [    "set -eu",    `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`,  ].join("\n");}; export const parseWslRuntimeRoot = (stdout: string): string | null => {  const prefix = "runtimeRoot:";  const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));  if (line === undefined) return null;  const runtimeRoot = line.slice(prefix.length).replace(/\r$/, "");  return runtimeRoot.startsWith("/") ? runtimeRoot : null;}; const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; export const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>  exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE    ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support."    : null; const NODE_PTY_PROBE_SCRIPT = (  linuxServerDir: string,

Lines 1–500 of 1269.