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.TaggedError<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",}; 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.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; 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; 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,) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"Lines 1–500 of 1268. Load more
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.TaggedError<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", }; 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. 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; 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; 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, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"