| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 11 | |
| 12 | import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; |
| 13 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| 14 | |
| 15 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 16 | import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; |
| 17 | |
| 18 | const PROCESS_TERMINATE_GRACE = Duration.seconds(1); |
| 19 | const LIST_TIMEOUT = Duration.seconds(8); |
| 20 | const PRE_WARM_TIMEOUT = Duration.seconds(10); |
| 21 | const WSLPATH_TIMEOUT = Duration.seconds(10); |
| 22 | const PROBE_TIMEOUT = Duration.seconds(10); |
| 23 | const TOOLCHAIN_TIMEOUT = Duration.seconds(10); |
| 24 | const BUILD_TIMEOUT = Duration.minutes(5); |
| 25 | const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); |
| 26 | const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); |
| 27 | const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); |
| 28 | const USER_HOME_TIMEOUT = Duration.seconds(5); |
| 29 | const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; |
| 30 | const BUILD_TRANSPORT_RETRY_LIMIT = 2; |
| 31 | |
| 32 | export interface EnsureWslNodePtyOptions { |
| 33 | readonly allowBuild?: boolean; |
| 34 | readonly nodeEngineRange?: string | null; |
| 35 | } |
| 36 | |
| 37 | // The packaged WSL runtime archive plus the SHA-256 identity the build recorded |
| 38 | // for it. The cache key derives from the same digest, and installation verifies |
| 39 | // the bytes before promoting the extracted tree. |
| 40 | export interface WslRuntimeArchive { |
| 41 | readonly windowsPath: string; |
| 42 | readonly runtimeId: string; |
| 43 | readonly sha256: string; |
| 44 | } |
| 45 | |
| 46 | export type PrepareWslRuntimeResult = |
| 47 | | { |
| 48 | readonly ok: true; |
| 49 | readonly linuxAppRoot: string; |
| 50 | } |
| 51 | | { |
| 52 | readonly ok: false; |
| 53 | readonly reason: string; |
| 54 | }; |
| 55 | |
| 56 | export type EnsureWslNodePtyResult = |
| 57 | | { |
| 58 | readonly ok: true; |
| 59 | readonly nodePath: string; |
| 60 | readonly resolvedPath: string; |
| 61 | } |
| 62 | | { |
| 63 | readonly ok: false; |
| 64 | readonly reason: string; |
| 65 | readonly fatal: boolean; |
| 66 | readonly retryLimit?: number; |
| 67 | }; |
| 68 | |
| 69 | // Outcome of asking the staged self-contained runtime to prove itself. Any |
| 70 | // failure sends the launch to the mounted server tree; the caller decides what |
| 71 | // to do with the cache. |
| 72 | export type ProbeWslRuntimeResult = |
| 73 | | { |
| 74 | readonly ok: true; |
| 75 | readonly resolvedPath: string; |
| 76 | } |
| 77 | | { |
| 78 | readonly ok: false; |
| 79 | readonly reason: string; |
| 80 | }; |
| 81 | |
| 82 | export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()( |
| 83 | "DesktopWslDistroListError", |
| 84 | { reason: Schema.String }, |
| 85 | ) { |
| 86 | override get message(): string { |
| 87 | return this.reason; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); |
| 92 | |
| 93 | export class DesktopWslEnvironment extends Context.Service< |
| 94 | DesktopWslEnvironment, |
| 95 | { |
| 96 | readonly isAvailable: Effect.Effect<boolean>; |
| 97 | // Best-effort enumeration for renderer UX. Backend health checks must use |
| 98 | // probeDistros so a transient command failure is not mistaken for a |
| 99 | // successful empty installation. |
| 100 | readonly listDistros: Effect.Effect<readonly WslDistro[]>; |
| 101 | readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>; |
| 102 | readonly preWarm: (distro: string | null) => Effect.Effect<void>; |
| 103 | readonly windowsToWslPath: ( |
| 104 | distro: string | null, |
| 105 | windowsPath: string, |
| 106 | ) => Effect.Effect<Option.Option<string>>; |
| 107 | // Resolves the user's Linux home dir inside the chosen distro (e.g. |
| 108 | // "/home/josh"). Used by the folder picker to expand `~` correctly. |
| 109 | readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>; |
| 110 | // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter |
| 111 | // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's |
| 112 | // httpBaseUrl so the renderer can reach it without relying on wslhost's |
| 113 | // localhost→WSL automatic forwarding, which is flaky in practice |
| 114 | // (the backend can be listening for 30+ seconds before wslhost starts |
| 115 | // forwarding 127.0.0.1:port to WSL-side localhost). |
| 116 | readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>; |
| 117 | readonly prepareRuntime: ( |
| 118 | distro: string | null, |
| 119 | archive: WslRuntimeArchive, |
| 120 | ) => Effect.Effect<PrepareWslRuntimeResult>; |
| 121 | readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 122 | // Marks a staged runtime as unusable so the next launch reinstalls it. |
| 123 | readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 124 | // Proves a staged self-contained runtime can run (`<root>/t3 --version`) |
| 125 | // and resolves the user's PATH, including version-managed Node for provider |
| 126 | // CLIs. Node is optional; the mounted tree still requires ensureNodePty. |
| 127 | readonly probeRuntime: ( |
| 128 | distro: string | null, |
| 129 | linuxAppRoot: string, |
| 130 | ) => Effect.Effect<ProbeWslRuntimeResult>; |
| 131 | readonly ensureNodePty: ( |
| 132 | distro: string | null, |
| 133 | linuxAppRoot: string, |
| 134 | options?: EnsureWslNodePtyOptions, |
| 135 | ) => Effect.Effect<EnsureWslNodePtyResult>; |
| 136 | } |
| 137 | >()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} |
| 138 | |
| 139 | const buildDistroArgs = (distro: string | null): ReadonlyArray<string> => |
| 140 | distro ? ["-d", distro] : []; |
| 141 | |
| 142 | const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => { |
| 143 | let totalLength = 0; |
| 144 | for (const arr of arrays) totalLength += arr.byteLength; |
| 145 | const out = new Uint8Array(totalLength); |
| 146 | let offset = 0; |
| 147 | for (const arr of arrays) { |
| 148 | out.set(arr, offset); |
| 149 | offset += arr.byteLength; |
| 150 | } |
| 151 | return out; |
| 152 | }; |
| 153 | |
| 154 | const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); |
| 155 | |
| 156 | interface ShellResult { |
| 157 | readonly exitCode: number; |
| 158 | readonly stdout: string; |
| 159 | readonly stderr: string; |
| 160 | readonly transportFailure: "timeout" | "spawn" | "process" | null; |
| 161 | } |
| 162 | |
| 163 | const TIMEOUT_RESULT: ShellResult = { |
| 164 | exitCode: 124, |
| 165 | stdout: "", |
| 166 | stderr: "\n[timeout]", |
| 167 | transportFailure: "timeout", |
| 168 | }; |
| 169 | |
| 170 | const formatWslShellTransportFailureReason = ( |
| 171 | failure: ShellResult["transportFailure"], |
| 172 | subject = "Node.js", |
| 173 | ): string | null => { |
| 174 | switch (failure) { |
| 175 | case "timeout": |
| 176 | return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; |
| 177 | case "spawn": |
| 178 | return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; |
| 179 | case "process": |
| 180 | return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; |
| 181 | case null: |
| 182 | return null; |
| 183 | } |
| 184 | }; |
| 185 | |
| 186 | // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node |
| 187 | // the same way. Passing the engine range lets the resolver fall through to |
| 188 | // version managers like nvm when a system node exists but is too old. |
| 189 | const buildWslNodeEnvPreamble = ( |
| 190 | nodeEngineRange?: string | null, |
| 191 | ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} |
| 192 | ensure_remote_node_path || true |
| 193 | `; |
| 194 | |
| 195 | // wsl.exe re-escapes args before forwarding them to the Linux side, which |
| 196 | // mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to |
| 197 | // avoid passing it on the command line at all. |
| 198 | const runWslShell = ( |
| 199 | distro: string | null, |
| 200 | bashScript: string, |
| 201 | timeout: Duration.Duration, |
| 202 | options: { |
| 203 | readonly nodeEngineRange?: string | null; |
| 204 | readonly resolveNode?: boolean; |
| 205 | } = {}, |
| 206 | ): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => { |
| 207 | const spawner = ChildProcessSpawner.ChildProcessSpawner; |
| 208 | // Node probes use a login bash so profile-managed PATH entries and supported |
| 209 | // version managers are available. Runtime installation needs only POSIX tools, |
| 210 | // so it skips profile loading and runs sh directly. |
| 211 | const resolveNode = options.resolveNode !== false; |
| 212 | const command = ChildProcess.make( |
| 213 | "wsl.exe", |
| 214 | resolveNode |
| 215 | ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] |
| 216 | : [...buildDistroArgs(distro), "--exec", "sh", "-s"], |
| 217 | { |
| 218 | stdin: Stream.encodeText( |
| 219 | Stream.make( |
| 220 | resolveNode |
| 221 | ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` |
| 222 | : bashScript, |
| 223 | ), |
| 224 | ), |
| 225 | stdout: "pipe", |
| 226 | stderr: "pipe", |
| 227 | killSignal: "SIGTERM", |
| 228 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 229 | }, |
| 230 | ); |
| 231 | |
| 232 | return Effect.scoped( |
| 233 | Effect.gen(function* () { |
| 234 | const spawnerService = yield* spawner; |
| 235 | const spawnResult = yield* spawnerService.spawn(command).pipe( |
| 236 | Effect.match({ |
| 237 | onFailure: (error) => ({ _tag: "Failure", error }) as const, |
| 238 | onSuccess: (handle) => ({ _tag: "Success", handle }) as const, |
| 239 | }), |
| 240 | ); |
| 241 | if (spawnResult._tag === "Failure") { |
| 242 | return { |
| 243 | exitCode: 127, |
| 244 | stdout: "", |
| 245 | stderr: `\n${spawnResult.error.message}`, |
| 246 | transportFailure: "spawn", |
| 247 | } satisfies ShellResult; |
| 248 | } |
| 249 | const handle = spawnResult.handle; |
| 250 | // Drain stdout and stderr concurrently so neither pipe buffer can fill |
| 251 | // and stall the child (node-gyp rebuild emits large output on both). |
| 252 | const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all( |
| 253 | [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode], |
| 254 | { concurrency: "unbounded" }, |
| 255 | ); |
| 256 | return { |
| 257 | exitCode: exitCode as unknown as number, |
| 258 | stdout: decodeUtf8(concatChunks(stdoutBytes)), |
| 259 | stderr: decodeUtf8(concatChunks(stderrBytes)), |
| 260 | transportFailure: null, |
| 261 | } satisfies ShellResult; |
| 262 | }), |
| 263 | ).pipe( |
| 264 | Effect.timeoutOption(timeout), |
| 265 | Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)), |
| 266 | Effect.catch((error) => |
| 267 | Effect.succeed<ShellResult>({ |
| 268 | exitCode: 127, |
| 269 | stdout: "", |
| 270 | stderr: `\n${error.message}`, |
| 271 | transportFailure: "process", |
| 272 | }), |
| 273 | ), |
| 274 | ); |
| 275 | }; |
| 276 | |
| 277 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; |
| 278 | |
| 279 | // Holds the sha256 of the runtime's `t3` executable, written when the install |
| 280 | // promotes a verified tree. Presence alone only says an install once finished |
| 281 | // here; the digest is what lets a later launch prove the entry still is what |
| 282 | // that install wrote. |
| 283 | const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; |
| 284 | const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; |
| 285 | const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; |
| 286 | |
| 287 | const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); |
| 288 | |
| 289 | // `archiveSha256` is the digest the build recorded alongside the archive. The |
| 290 | // install verifies the bytes before extracting, so an archive can never be |
| 291 | // promoted under an identity that does not describe it. |
| 292 | export const buildWslRuntimeInstallScript = ( |
| 293 | linuxArchivePath: string, |
| 294 | runtimeId: string, |
| 295 | archiveSha256: string, |
| 296 | ): string => { |
| 297 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); |
| 298 | return [ |
| 299 | "set -eu", |
| 300 | 'runtime_parent="$HOME/.t3/wsl-runtime"', |
| 301 | `runtime_root="$runtime_parent/${safeRuntimeId}"`, |
| 302 | `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, |
| 303 | // The runtime is a self-contained `t3` executable with Node inside, so the |
| 304 | // readiness proof is the same one the SSH runner and the CLI installers |
| 305 | // use: the file is executable and `t3 --version` exits 0. That covers the |
| 306 | // truncated-binary and wrong-arch cases without a separate native probe. |
| 307 | "runtime_entry_runs() {", |
| 308 | ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', |
| 309 | "}", |
| 310 | // Hashing the entry is what tells a working cache from one whose `t3` was |
| 311 | // swapped or half-written after install: the file is still there and may |
| 312 | // even still run, and launch then picks an executable that is not what |
| 313 | // this install verified. Hashing the executable measures in tens of |
| 314 | // milliseconds inside the distro, once per launch, against a cold |
| 315 | // reinstall of a few hundred megabytes. |
| 316 | "runtime_server_entry_digest() {", |
| 317 | ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, |
| 318 | "}", |
| 319 | "runtime_is_ready() {", |
| 320 | ' [ -f "$ready_marker" ] &&', |
| 321 | ' runtime_entry_runs "$runtime_root" &&', |
| 322 | // An empty or unreadable marker is a miss, not a pass: that is what a |
| 323 | // runtime installed before the marker carried a digest looks like, and one |
| 324 | // reinstall is the cheapest way to make it verifiable from then on. |
| 325 | ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`, |
| 326 | ' [ -n "$recorded_entry_digest" ] &&', |
| 327 | ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', |
| 328 | "}", |
| 329 | 'mkdir -p "$runtime_parent"', |
| 330 | `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`, |
| 331 | "trap 'exit 1' HUP INT TERM", |
| 332 | 'exec 9> "$runtime_lock"', |
| 333 | "flock -x 9", |
| 334 | "if runtime_is_ready; then", |
| 335 | ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, |
| 336 | ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, |
| 337 | " exit 0", |
| 338 | "fi", |
| 339 | // Hash only on a cache miss: a warm launch already exited above, and a cold |
| 340 | // install is about to read the whole archive through tar anyway. `set -eu` |
| 341 | // turns a distro without sha256sum into an install failure, which falls back |
| 342 | // to the mounted server tree rather than trusting unverified bytes. |
| 343 | `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`, |
| 344 | `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`, |
| 345 | ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`, |
| 346 | " exit 1", |
| 347 | "fi", |
| 348 | // A backend can still be running out of an unready tree: the probe revokes |
| 349 | // the ready marker without stopping the process it just failed for, and |
| 350 | // invalidation deliberately leaves the tree in place for exactly that |
| 351 | // reason. Deleting it here unlinks node_modules from under a live backend, |
| 352 | // which then breaks the moment it lazily loads anything it had not already |
| 353 | // read. Move it aside either way, but only delete it now when nothing is |
| 354 | // running from it; otherwise hand it to the pruner's scratch sweep, which |
| 355 | // is what that delay is for. A process's cmdline keeps the pre-rename path, |
| 356 | // so this has to be asked before the move, not after. This script arrives |
| 357 | // on stdin, so it cannot match itself. |
| 358 | "runtime_in_use() {", |
| 359 | // No /proc means no way to tell, and guessing wrong costs a live backend |
| 360 | // its runtime. Keeping the tree only costs disk until the sweep runs. |
| 361 | " [ -d /proc/1 ] || return 0", |
| 362 | ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', |
| 363 | "}", |
| 364 | 'if [ -e "$runtime_root" ]; then', |
| 365 | ' if runtime_in_use "$runtime_root"; then', |
| 366 | " runtime_root_in_use=1", |
| 367 | " else", |
| 368 | " runtime_root_in_use=0", |
| 369 | " fi", |
| 370 | ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`, |
| 371 | ' rmdir "$runtime_stale"', |
| 372 | ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then', |
| 373 | ' if [ "$runtime_root_in_use" = 1 ]; then', |
| 374 | // Renaming keeps the directory's old mtime, so restart the cleanup clock. |
| 375 | ' touch "$runtime_stale"', |
| 376 | " else", |
| 377 | ' rm -rf "$runtime_stale"', |
| 378 | " fi", |
| 379 | " fi", |
| 380 | "fi", |
| 381 | `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, |
| 382 | 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', |
| 383 | "trap cleanup_runtime_install EXIT", |
| 384 | // The release archive has one top-level `t3-<version>-linux-<arch>/` |
| 385 | // directory; strip it so the executable lands at `$runtime_root/t3`. |
| 386 | `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, |
| 387 | // Never write the ready marker over a tree whose executable does not run. |
| 388 | // Failing here drops out to the mounted-tree fallback, which is |
| 389 | // recoverable; promoting it would mark the defect ready and cache it. |
| 390 | 'if ! runtime_entry_runs "$runtime_tmp"; then', |
| 391 | " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", |
| 392 | " exit 1", |
| 393 | "fi", |
| 394 | // The archive's bytes were verified against archiveSha256 above, so the |
| 395 | // digest recorded here describes content this install proved. Every later |
| 396 | // warm reuse checks the entry against it. |
| 397 | 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', |
| 398 | 'if [ -z "$installed_entry_digest" ]; then', |
| 399 | " printf 'Could not hash the WSL runtime server entry\\n' >&2", |
| 400 | " exit 1", |
| 401 | "fi", |
| 402 | `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`, |
| 403 | 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then', |
| 404 | " :", |
| 405 | "elif runtime_is_ready; then", |
| 406 | ' rm -rf "$runtime_tmp"', |
| 407 | "else", |
| 408 | ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`, |
| 409 | " exit 1", |
| 410 | "fi", |
| 411 | `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, |
| 412 | `printf 'runtimeRoot:%s\\n' "$runtime_root"`, |
| 413 | ].join("\n"); |
| 414 | }; |
| 415 | |
| 416 | // An interrupted install leaves a dot-prefixed scratch directory behind. A cold |
| 417 | // install extracts a few hundred MB inside the distro, so two hours is far past |
| 418 | // any live install while still bounding how long an orphan survives. |
| 419 | const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120; |
| 420 | |
| 421 | export const buildWslRuntimePruneScript = (runtimeId: string): string => { |
| 422 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); |
| 423 | return [ |
| 424 | "set -eu", |
| 425 | 'runtime_parent="$HOME/.t3/wsl-runtime"', |
| 426 | `current_runtime="$runtime_parent/${safeRuntimeId}"`, |
| 427 | '[ -d "$runtime_parent" ] || exit 0', |
| 428 | // Serialize the whole retention decision so two backends cannot select |
| 429 | // different "previous" caches and delete around one another. |
| 430 | 'prune_lock="$runtime_parent/.prune.lock"', |
| 431 | 'exec 8> "$prune_lock"', |
| 432 | "flock -x 8", |
| 433 | // Without a way to see the distro's processes we cannot tell which caches |
| 434 | // are load-bearing, and the retention rules below are not safe on their own. |
| 435 | "[ -d /proc/1 ] || exit 0", |
| 436 | "runtime_in_use() {", |
| 437 | ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', |
| 438 | "}", |
| 439 | 'previous_runtime=""', |
| 440 | 'for candidate in "$runtime_parent"/sha256-*; do', |
| 441 | ' [ -d "$candidate" ] || continue', |
| 442 | ' [ "$candidate" != "$current_runtime" ] || continue', |
| 443 | ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`, |
| 444 | ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then', |
| 445 | ' previous_runtime="$candidate"', |
| 446 | " fi", |
| 447 | "done", |
| 448 | // Only this desktop-owned prefix is eligible. Markerless roots are broken |
| 449 | // caches left by invalidation and must not become permanent disk leaks. |
| 450 | 'for candidate in "$runtime_parent"/sha256-*; do', |
| 451 | ' [ -d "$candidate" ] || continue', |
| 452 | ' [ "$candidate" != "$current_runtime" ] || continue', |
| 453 | ' [ "$candidate" != "$previous_runtime" ] || continue', |
| 454 | ' ! runtime_in_use "$candidate" || continue', |
| 455 | " candidate_name=${candidate##*/}", |
| 456 | ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"', |
| 457 | ' exec 9> "$candidate_lock"', |
| 458 | // A held lock means another launch is installing or repairing this cache. |
| 459 | // Skip instead of waiting or deleting underneath it. |
| 460 | " flock -n 9 || continue", |
| 461 | ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`, |
| 462 | ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`, |
| 463 | " flock -u 9", |
| 464 | " continue", |
| 465 | " fi", |
| 466 | ' rm -rf -- "$candidate"', |
| 467 | " flock -u 9", |
| 468 | "done", |
| 469 | // Interrupted installs use dot-prefixed names under this dedicated parent. |
| 470 | 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', |
| 471 | ' [ -d "$scratch" ] || continue', |
| 472 | ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`, |
| 473 | ' rm -rf -- "$scratch"', |
| 474 | "done", |
| 475 | ].join("\n"); |
| 476 | }; |
| 477 | |
| 478 | // Drops the ready marker so the next launch reinstalls the runtime from the |
| 479 | // archive. Readiness is decided inside the install script, so a cached tree |
| 480 | // that passes there but fails the launch-time probe (a distro whose glibc the |
| 481 | // executable needs, a tree copied from another machine) would stay ready |
| 482 | // forever and fail on every launch. Only the probe can see that, so the probe |
| 483 | // is what revokes the marker. The tree itself is left in place: the install |
| 484 | // script moves an unready root aside before extracting. |
| 485 | export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { |
| 486 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); |
| 487 | return [ |
| 488 | "set -eu", |
| 489 | `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, |
| 490 | ].join("\n"); |
| 491 | }; |
| 492 | |
| 493 | export const parseWslRuntimeRoot = (stdout: string): string | null => { |
| 494 | const prefix = "runtimeRoot:"; |
| 495 | const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); |
| 496 | if (line === undefined) return null; |
| 497 | const runtimeRoot = line.slice(prefix.length).replace(/\r$/, ""); |
| 498 | return runtimeRoot.startsWith("/") ? runtimeRoot : null; |
| 499 | }; |
| 500 | |
| 501 | // The mounted server tree carries no Linux pty.node unless the build put one |
| 502 | // there. Distinct from a binary that is present but will not load, which is a |
| 503 | // distro problem rather than a build problem. |
| 504 | const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; |
| 505 | |
| 506 | const formatNodePtyProbeFailureReason = (exitCode: number): string | null => |
| 507 | exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE |
| 508 | ? "WSL support is missing from this build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." |
| 509 | : null; |
| 510 | |
| 511 | // Captures the login-shell PATH as `resolvedPath:` so the launch can forward the |
| 512 | // user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. |
| 513 | const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; |
| 514 | |
| 515 | const NODE_PTY_PROBE_SCRIPT = ( |
| 516 | linuxServerDir: string, |
| 517 | ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" |
| 518 | printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" |
| 519 | ${RESOLVED_PATH_LINE} |
| 520 | cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 |
| 521 | // The WSL Node can't read inside app.asar, so confirm what the server needs is |
| 522 | // unpacked on the real filesystem before reporting the backend healthy. Exit 3 |
| 523 | // marks this distinct from a node-pty binary problem so the caller can report |
| 524 | // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at |
| 525 | // launch (which, in wsl-only mode, would just fail to launch with no fallback). |
| 526 | // |
| 527 | // The sentinel must be a package the CLI bundle leaves external. It used to be |
| 528 | // "effect", back when the bundle externalized its runtime deps and the whole |
| 529 | // node_modules tree was unpacked. The bundle now inlines its JS dependencies, |
| 530 | // so "effect" no longer exists on disk and only the native packages do — |
| 531 | // resolving node-pty is what actually validates the unpacked tree. |
| 532 | try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } |
| 533 | const fs = require("node:fs"); |
| 534 | const path = require("node:path"); |
| 535 | const pkgDir = path.dirname(require.resolve("node-pty/package.json")); |
| 536 | // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across |
| 537 | // Node versions — require() succeeding IS the real compatibility test. Look in |
| 538 | // the same places node-pty's own loader does. |
| 539 | const candidates = [ |
| 540 | path.join(pkgDir, "build", "Release", "pty.node"), |
| 541 | path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), |
| 542 | ]; |
| 543 | if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); |
| 544 | require("node-pty"); |
| 545 | NODE`; |
| 546 | |
| 547 | // Readiness proof for a staged self-contained runtime: the executable runs and |
| 548 | // reports its version. Provider CLIs may still need version-managed Node, so |
| 549 | // resolve it before capturing PATH without requiring it for runtime readiness. |
| 550 | // A distro without bash falls back to the PATH sh was started with. |
| 551 | export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => |
| 552 | [ |
| 553 | `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, |
| 554 | `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, |
| 555 | ].join("\n"); |
| 556 | |
| 557 | const TOOLCHAIN_CHECK_SCRIPT = [ |
| 558 | "for tool in node make g++ python3; do", |
| 559 | ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', |
| 560 | "done", |
| 561 | "if command -v node >/dev/null 2>&1; then", |
| 562 | ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`, |
| 563 | ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi', |
| 564 | "fi", |
| 565 | ].join("\n"); |
| 566 | |
| 567 | const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => |
| 568 | [ |
| 569 | "set -e", |
| 570 | `cd ${shellQuote(linuxServerDir)}`, |
| 571 | `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, |
| 572 | `cd "$pkg_dir"`, |
| 573 | "npx --yes node-gyp rebuild", |
| 574 | `node -e 'require("node-pty")'`, |
| 575 | ].join("\n"); |
| 576 | |
| 577 | export interface ToolchainReport { |
| 578 | readonly missingTools: ReadonlyArray<string>; |
| 579 | readonly nodeVersion: string | null; |
| 580 | } |
| 581 | |
| 582 | export const parseToolchainReport = (stdout: string): ToolchainReport => { |
| 583 | const lines = stdout |
| 584 | .split("\n") |
| 585 | .map((line) => line.trim()) |
| 586 | .filter((line) => line.length > 0); |
| 587 | const missingTools = lines |
| 588 | .filter((line) => line.startsWith("missing:")) |
| 589 | .map((line) => line.slice("missing:".length)); |
| 590 | const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:")); |
| 591 | const nodeVersion = nodeVersionLine |
| 592 | ? nodeVersionLine.slice("nodeVersion:".length).trim() || null |
| 593 | : null; |
| 594 | return { missingTools, nodeVersion }; |
| 595 | }; |
| 596 | |
| 597 | // Pulls the absolute node path the WSL distro resolved after the shared remote |
| 598 | // resolver repaired PATH. Returns null when no node was found, which the caller |
| 599 | // turns into an actionable "install Node" message instead of a confusing |
| 600 | // node-pty error. |
| 601 | export const parseNodePath = (stdout: string): string | null => { |
| 602 | const path = stdout |
| 603 | .split("\n") |
| 604 | .map((line) => line.trim()) |
| 605 | .filter((line) => line.startsWith("nodePath:")) |
| 606 | .map((line) => line.slice("nodePath:".length).trim()) |
| 607 | .find((value) => value.length > 0); |
| 608 | return path ?? null; |
| 609 | }; |
| 610 | |
| 611 | export const parseNodeVersion = (stdout: string): string | null => { |
| 612 | const version = stdout |
| 613 | .split("\n") |
| 614 | .map((line) => line.trim()) |
| 615 | .filter((line) => line.startsWith("nodeVersion:")) |
| 616 | .map((line) => line.slice("nodeVersion:".length).trim()) |
| 617 | .find((value) => value.length > 0); |
| 618 | return version ?? null; |
| 619 | }; |
| 620 | |
| 621 | // Captures the login-shell PATH after the shared resolver has loaded version |
| 622 | // managers. Preserve the value byte-for-byte apart from a Windows-style CR so |
| 623 | // paths containing spaces or apostrophes can be forwarded as one env argv. |
| 624 | export const parseResolvedPath = (stdout: string): string | null => { |
| 625 | const prefix = "resolvedPath:"; |
| 626 | const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); |
| 627 | if (line === undefined) return null; |
| 628 | const resolvedPath = line.slice(prefix.length).replace(/\r$/, ""); |
| 629 | return resolvedPath.length > 0 ? resolvedPath : null; |
| 630 | }; |
| 631 | |
| 632 | export const formatMissingToolsReason = ( |
| 633 | report: ToolchainReport, |
| 634 | requiredRange: string | null, |
| 635 | ): string | null => { |
| 636 | const nodeMissing = report.missingTools.includes("node"); |
| 637 | const nodeOutOfRange = |
| 638 | !nodeMissing && |
| 639 | requiredRange !== null && |
| 640 | report.nodeVersion !== null && |
| 641 | !satisfiesSemverRange(report.nodeVersion, requiredRange); |
| 642 | const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node"); |
| 643 | |
| 644 | if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) { |
| 645 | return null; |
| 646 | } |
| 647 | |
| 648 | const issues: string[] = []; |
| 649 | const remediations: string[] = []; |
| 650 | |
| 651 | if (nodeMissing) { |
| 652 | issues.push("node"); |
| 653 | remediations.push( |
| 654 | `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`, |
| 655 | ); |
| 656 | } else if (nodeOutOfRange) { |
| 657 | issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`); |
| 658 | remediations.push( |
| 659 | `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`, |
| 660 | ); |
| 661 | } |
| 662 | |
| 663 | if (buildToolsMissing.length > 0) { |
| 664 | issues.push(...buildToolsMissing); |
| 665 | remediations.push( |
| 666 | "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)", |
| 667 | ); |
| 668 | } |
| 669 | |
| 670 | return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; |
| 671 | }; |
| 672 | |
| 673 | const probeWslRuntimeImpl = ( |
| 674 | distro: string | null, |
| 675 | linuxAppRoot: string, |
| 676 | ): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> => |
| 677 | Effect.gen(function* () { |
| 678 | const probe = yield* runWslShell( |
| 679 | distro, |
| 680 | buildWslRuntimeProbeScript(linuxAppRoot), |
| 681 | PROBE_TIMEOUT, |
| 682 | { |
| 683 | resolveNode: false, |
| 684 | }, |
| 685 | ); |
| 686 | const transportFailureReason = formatWslShellTransportFailureReason( |
| 687 | probe.transportFailure, |
| 688 | "the staged runtime", |
| 689 | ); |
| 690 | if (transportFailureReason !== null) { |
| 691 | return { ok: false, reason: transportFailureReason } as const; |
| 692 | } |
| 693 | if (probe.exitCode !== 0) { |
| 694 | const trimmedTail = probe.stderr.trim().slice(-500); |
| 695 | return { |
| 696 | ok: false, |
| 697 | reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, |
| 698 | } as const; |
| 699 | } |
| 700 | const resolvedPath = parseResolvedPath(probe.stdout); |
| 701 | if (resolvedPath === null) { |
| 702 | return { |
| 703 | ok: false, |
| 704 | reason: "WSL login-shell PATH could not be resolved during backend preflight.", |
| 705 | } as const; |
| 706 | } |
| 707 | return { ok: true, resolvedPath } as const; |
| 708 | }); |
| 709 | |
| 710 | const ensureNodePtyImpl = ( |
| 711 | distro: string | null, |
| 712 | linuxRepoRoot: string, |
| 713 | options: EnsureWslNodePtyOptions = {}, |
| 714 | ): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> => |
| 715 | Effect.gen(function* () { |
| 716 | // node-pty lives in the apps/server workspace's node_modules; resolve from |
| 717 | // there rather than the monorepo root, where Bun's hoist layout omits it. |
| 718 | const linuxServerDir = `${linuxRepoRoot}/apps/server`; |
| 719 | |
| 720 | const probe = yield* runWslShell( |
| 721 | distro, |
| 722 | NODE_PTY_PROBE_SCRIPT(linuxServerDir), |
| 723 | PROBE_TIMEOUT, |
| 724 | options, |
| 725 | ); |
| 726 | const nodePath = parseNodePath(probe.stdout); |
| 727 | const resolvedPath = parseResolvedPath(probe.stdout); |
| 728 | |
| 729 | const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); |
| 730 | if (transportFailureReason !== null) { |
| 731 | return { |
| 732 | ok: false, |
| 733 | reason: transportFailureReason, |
| 734 | fatal: false, |
| 735 | } as const; |
| 736 | } |
| 737 | |
| 738 | // No node at all, even after the shared resolver repaired PATH. Surface |
| 739 | // the specific, actionable toolchain message rather than a confusing |
| 740 | // node-pty error, and don't try to build. |
| 741 | if (nodePath === null) { |
| 742 | const toolchainCheck = yield* runWslShell( |
| 743 | distro, |
| 744 | TOOLCHAIN_CHECK_SCRIPT, |
| 745 | TOOLCHAIN_TIMEOUT, |
| 746 | options, |
| 747 | ); |
| 748 | const toolchainTransportFailure = formatWslShellTransportFailureReason( |
| 749 | toolchainCheck.transportFailure, |
| 750 | ); |
| 751 | if (toolchainTransportFailure !== null) { |
| 752 | return { |
| 753 | ok: false, |
| 754 | reason: toolchainTransportFailure, |
| 755 | fatal: false, |
| 756 | retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, |
| 757 | } as const; |
| 758 | } |
| 759 | const report = parseToolchainReport(toolchainCheck.stdout); |
| 760 | const reason = |
| 761 | formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? |
| 762 | "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; |
| 763 | return { ok: false, reason, fatal: true } as const; |
| 764 | } |
| 765 | |
| 766 | if (resolvedPath === null) { |
| 767 | return { |
| 768 | ok: false, |
| 769 | reason: "WSL login-shell PATH could not be resolved during backend preflight.", |
| 770 | fatal: true, |
| 771 | } as const; |
| 772 | } |
| 773 | |
| 774 | // The packages the server bundle leaves external (node-pty and the other |
| 775 | // native addons) couldn't be resolved on the WSL filesystem — a packaging |
| 776 | // regression, since those must be unpacked from the asar. Fatal so wsl-only |
| 777 | // mode falls back to Windows and dual mode surfaces the reason inline, |
| 778 | // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it |
| 779 | // actually launches. |
| 780 | if (probe.exitCode === 3) { |
| 781 | return { |
| 782 | ok: false, |
| 783 | reason: |
| 784 | 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', |
| 785 | fatal: true, |
| 786 | } as const; |
| 787 | } |
| 788 | |
| 789 | if (probe.exitCode === 0) { |
| 790 | const rawVersion = parseNodeVersion(probe.stdout); |
| 791 | if ( |
| 792 | rawVersion !== null && |
| 793 | options.nodeEngineRange && |
| 794 | !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) |
| 795 | ) { |
| 796 | const range = options.nodeEngineRange.trim(); |
| 797 | return { |
| 798 | ok: false, |
| 799 | reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, |
| 800 | fatal: true, |
| 801 | } as const; |
| 802 | } |
| 803 | return { ok: true, nodePath, resolvedPath } as const; |
| 804 | } |
| 805 | |
| 806 | if (options.allowBuild !== true) { |
| 807 | const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); |
| 808 | if (packagedProbeFailure !== null) { |
| 809 | return { |
| 810 | ok: false, |
| 811 | reason: packagedProbeFailure, |
| 812 | fatal: true, |
| 813 | } as const; |
| 814 | } |
| 815 | } |
| 816 | |
| 817 | // node is present but node-pty's native module didn't load. |
| 818 | const toolchainCheck = yield* runWslShell( |
| 819 | distro, |
| 820 | TOOLCHAIN_CHECK_SCRIPT, |
| 821 | TOOLCHAIN_TIMEOUT, |
| 822 | options, |
| 823 | ); |
| 824 | const toolchainTransportFailure = formatWslShellTransportFailureReason( |
| 825 | toolchainCheck.transportFailure, |
| 826 | ); |
| 827 | if (toolchainTransportFailure !== null) { |
| 828 | return { |
| 829 | ok: false, |
| 830 | reason: toolchainTransportFailure, |
| 831 | fatal: false, |
| 832 | retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, |
| 833 | } as const; |
| 834 | } |
| 835 | const report = parseToolchainReport(toolchainCheck.stdout); |
| 836 | |
| 837 | if (options.allowBuild !== true) { |
| 838 | // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, |
| 839 | // or network is needed — and we must not nag the user to install build |
| 840 | // tools they don't need. Still surface a missing/too-old Node (both the |
| 841 | // prebuilt and the server require a compatible Node); otherwise reaching |
| 842 | // here means the bundled binary itself couldn't load, which is almost |
| 843 | // always an unsupported CPU architecture or incompatible system libraries. |
| 844 | const nodeOnlyReason = formatMissingToolsReason( |
| 845 | { |
| 846 | missingTools: report.missingTools.filter((tool) => tool === "node"), |
| 847 | nodeVersion: report.nodeVersion, |
| 848 | }, |
| 849 | options.nodeEngineRange?.trim() || null, |
| 850 | ); |
| 851 | return { |
| 852 | ok: false, |
| 853 | reason: |
| 854 | nodeOnlyReason ?? |
| 855 | "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", |
| 856 | fatal: true, |
| 857 | } as const; |
| 858 | } |
| 859 | |
| 860 | // Dev only: no prebuilt is bundled in a checkout, so compile node-pty from |
| 861 | // source. Run the toolchain check first so a missing compiler or out-of-range |
| 862 | // Node surfaces a specific, actionable message instead of an opaque node-gyp |
| 863 | // failure. Developers have the toolchain; end users never reach this path. |
| 864 | const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); |
| 865 | if (missingReason !== null) { |
| 866 | return { ok: false, reason: missingReason, fatal: true } as const; |
| 867 | } |
| 868 | |
| 869 | const build = yield* runWslShell( |
| 870 | distro, |
| 871 | NODE_PTY_BUILD_SCRIPT(linuxServerDir), |
| 872 | BUILD_TIMEOUT, |
| 873 | options, |
| 874 | ); |
| 875 | const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); |
| 876 | if (buildTransportFailure !== null) { |
| 877 | return { |
| 878 | ok: false, |
| 879 | reason: buildTransportFailure, |
| 880 | fatal: false, |
| 881 | retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, |
| 882 | } as const; |
| 883 | } |
| 884 | if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; |
| 885 | const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); |
| 886 | return { |
| 887 | ok: false, |
| 888 | reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, |
| 889 | fatal: true, |
| 890 | } as const; |
| 891 | }); |
| 892 | |
| 893 | const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( |
| 894 | distro: string | null, |
| 895 | archive: WslRuntimeArchive, |
| 896 | windowsToWslPath: ( |
| 897 | distro: string | null, |
| 898 | windowsPath: string, |
| 899 | ) => Effect.Effect<Option.Option<string>>, |
| 900 | ): Effect.fn.Return<PrepareWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> { |
| 901 | const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); |
| 902 | if (Option.isNone(linuxArchivePath)) { |
| 903 | return { |
| 904 | ok: false, |
| 905 | reason: `wslpath conversion failed for ${archive.windowsPath}`, |
| 906 | } as const; |
| 907 | } |
| 908 | |
| 909 | const install = yield* runWslShell( |
| 910 | distro, |
| 911 | buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256), |
| 912 | RUNTIME_INSTALL_TIMEOUT, |
| 913 | { resolveNode: false }, |
| 914 | ); |
| 915 | if (install.transportFailure !== null) { |
| 916 | return { |
| 917 | ok: false, |
| 918 | reason: |
| 919 | install.transportFailure === "timeout" |
| 920 | ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry." |
| 921 | : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.", |
| 922 | } as const; |
| 923 | } |
| 924 | if (install.exitCode !== 0) { |
| 925 | const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500); |
| 926 | return { |
| 927 | ok: false, |
| 928 | reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`, |
| 929 | } as const; |
| 930 | } |
| 931 | |
| 932 | const linuxAppRoot = parseWslRuntimeRoot(install.stdout); |
| 933 | return linuxAppRoot === null |
| 934 | ? { |
| 935 | ok: false, |
| 936 | reason: "WSL runtime installation completed without reporting its cache path.", |
| 937 | } |
| 938 | : { ok: true, linuxAppRoot }; |
| 939 | }); |
| 940 | |
| 941 | const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* ( |
| 942 | distro: string | null, |
| 943 | runtimeId: string, |
| 944 | ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> { |
| 945 | const result = yield* runWslShell( |
| 946 | distro, |
| 947 | buildWslRuntimePruneScript(runtimeId), |
| 948 | RUNTIME_PRUNE_TIMEOUT, |
| 949 | { resolveNode: false }, |
| 950 | ); |
| 951 | if (result.transportFailure === null && result.exitCode === 0) return; |
| 952 | |
| 953 | const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); |
| 954 | yield* Effect.logWarning("Could not prune old WSL runtime caches.", { |
| 955 | distro, |
| 956 | runtimeId, |
| 957 | detail: detail || `exit ${result.exitCode}`, |
| 958 | }); |
| 959 | }); |
| 960 | |
| 961 | const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* ( |
| 962 | distro: string | null, |
| 963 | runtimeId: string, |
| 964 | ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> { |
| 965 | const result = yield* runWslShell( |
| 966 | distro, |
| 967 | buildWslRuntimeInvalidateScript(runtimeId), |
| 968 | RUNTIME_INVALIDATE_TIMEOUT, |
| 969 | { resolveNode: false }, |
| 970 | ); |
| 971 | if (result.transportFailure === null && result.exitCode === 0) return; |
| 972 | |
| 973 | const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); |
| 974 | // Best effort: the caller has already fallen back to the mounted tree, so a |
| 975 | // failure here only costs the reinstall that would have repaired the cache. |
| 976 | yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", { |
| 977 | distro, |
| 978 | runtimeId, |
| 979 | detail: detail || `exit ${result.exitCode}`, |
| 980 | }); |
| 981 | }); |
| 982 | |
| 983 | export const probeWslDistros: Effect.Effect< |
| 984 | readonly WslDistro[], |
| 985 | DesktopWslDistroListError, |
| 986 | ChildProcessSpawner.ChildProcessSpawner |
| 987 | > = Effect.scoped( |
| 988 | Effect.gen(function* () { |
| 989 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 990 | const command = ChildProcess.make("wsl.exe", ["--list", "--verbose"], { |
| 991 | stdin: "ignore", |
| 992 | stdout: "pipe", |
| 993 | stderr: "ignore", |
| 994 | killSignal: "SIGTERM", |
| 995 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 996 | }); |
| 997 | const handle = yield* spawner.spawn(command); |
| 998 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); |
| 999 | const exitCode = yield* handle.exitCode; |
| 1000 | if ((exitCode as unknown as number) !== 0) { |
| 1001 | return yield* new DesktopWslDistroListError({ |
| 1002 | reason: `wsl.exe --list --verbose exited with code ${String(exitCode)}`, |
| 1003 | }); |
| 1004 | } |
| 1005 | return parseWslDistroList(Buffer.from(concatChunks(stdoutBytes))); |
| 1006 | }), |
| 1007 | ).pipe( |
| 1008 | Effect.mapError((error) => |
| 1009 | isDesktopWslDistroListError(error) |
| 1010 | ? error |
| 1011 | : new DesktopWslDistroListError({ |
| 1012 | reason: `Failed to run wsl.exe --list --verbose: ${error.message}`, |
| 1013 | }), |
| 1014 | ), |
| 1015 | Effect.timeoutOption(LIST_TIMEOUT), |
| 1016 | Effect.flatMap( |
| 1017 | Option.match({ |
| 1018 | onNone: () => |
| 1019 | new DesktopWslDistroListError({ |
| 1020 | reason: "wsl.exe --list --verbose timed out", |
| 1021 | }), |
| 1022 | onSome: Effect.succeed, |
| 1023 | }), |
| 1024 | ), |
| 1025 | ); |
| 1026 | |
| 1027 | const preWarmImpl = ( |
| 1028 | distro: string | null, |
| 1029 | ): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => |
| 1030 | Effect.scoped( |
| 1031 | Effect.gen(function* () { |
| 1032 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1033 | const command = ChildProcess.make("wsl.exe", [...buildDistroArgs(distro), "--", "true"], { |
| 1034 | stdin: "ignore", |
| 1035 | stdout: "ignore", |
| 1036 | stderr: "ignore", |
| 1037 | killSignal: "SIGTERM", |
| 1038 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 1039 | }); |
| 1040 | const handle = yield* spawner.spawn(command); |
| 1041 | yield* handle.exitCode; |
| 1042 | }), |
| 1043 | ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore); |
| 1044 | |
| 1045 | const windowsToWslPathImpl = ( |
| 1046 | distro: string | null, |
| 1047 | windowsPath: string, |
| 1048 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => { |
| 1049 | // wsl.exe interprets backslashes as escape chars; normalize to forward slashes. |
| 1050 | const normalized = windowsPath.replaceAll("\\", "/"); |
| 1051 | return Effect.scoped( |
| 1052 | Effect.gen(function* () { |
| 1053 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1054 | const command = ChildProcess.make( |
| 1055 | "wsl.exe", |
| 1056 | [...buildDistroArgs(distro), "--", "wslpath", "-u", normalized], |
| 1057 | { |
| 1058 | stdin: "ignore", |
| 1059 | stdout: "pipe", |
| 1060 | stderr: "ignore", |
| 1061 | killSignal: "SIGTERM", |
| 1062 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 1063 | }, |
| 1064 | ); |
| 1065 | const handle = yield* spawner.spawn(command); |
| 1066 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); |
| 1067 | const exitCode = yield* handle.exitCode; |
| 1068 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); |
| 1069 | const converted = decodeUtf8(concatChunks(stdoutBytes)).trim(); |
| 1070 | return converted.length > 0 ? Option.some(converted) : Option.none<string>(); |
| 1071 | }), |
| 1072 | ).pipe( |
| 1073 | Effect.timeoutOption(WSLPATH_TIMEOUT), |
| 1074 | Effect.map(Option.flatten), |
| 1075 | Effect.orElseSucceed(() => Option.none<string>()), |
| 1076 | ); |
| 1077 | }; |
| 1078 | |
| 1079 | const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; |
| 1080 | |
| 1081 | const getDistroIpImpl = ( |
| 1082 | distro: string | null, |
| 1083 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => |
| 1084 | Effect.scoped( |
| 1085 | Effect.gen(function* () { |
| 1086 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1087 | // `hostname -I` prints a space-separated list of all non-loopback |
| 1088 | // IPs the distro has bound. The first entry on the WSL2 default |
| 1089 | // network is always the eth0 vEthernet address Windows can reach |
| 1090 | // directly (no wslhost forwarding required). |
| 1091 | const command = ChildProcess.make( |
| 1092 | "wsl.exe", |
| 1093 | [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"], |
| 1094 | { |
| 1095 | stdin: "ignore", |
| 1096 | stdout: "pipe", |
| 1097 | stderr: "ignore", |
| 1098 | killSignal: "SIGTERM", |
| 1099 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 1100 | }, |
| 1101 | ); |
| 1102 | const handle = yield* spawner.spawn(command); |
| 1103 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); |
| 1104 | const exitCode = yield* handle.exitCode; |
| 1105 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); |
| 1106 | const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); |
| 1107 | const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part)); |
| 1108 | return candidate ? Option.some(candidate) : Option.none<string>(); |
| 1109 | }), |
| 1110 | ).pipe( |
| 1111 | Effect.timeoutOption(USER_HOME_TIMEOUT), |
| 1112 | Effect.map(Option.flatten), |
| 1113 | Effect.orElseSucceed(() => Option.none<string>()), |
| 1114 | ); |
| 1115 | |
| 1116 | const getUserHomeImpl = ( |
| 1117 | distro: string | null, |
| 1118 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => |
| 1119 | Effect.scoped( |
| 1120 | Effect.gen(function* () { |
| 1121 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1122 | const command = ChildProcess.make( |
| 1123 | "wsl.exe", |
| 1124 | // printf so there's no trailing newline noise; getent so we get the |
| 1125 | // real home from /etc/passwd even if $HOME is unset for some reason. |
| 1126 | [ |
| 1127 | ...buildDistroArgs(distro), |
| 1128 | "--", |
| 1129 | "sh", |
| 1130 | "-c", |
| 1131 | 'printf "%s" "$(getent passwd "$(id -un)" | cut -d: -f6)"', |
| 1132 | ], |
| 1133 | { |
| 1134 | stdin: "ignore", |
| 1135 | stdout: "pipe", |
| 1136 | stderr: "ignore", |
| 1137 | killSignal: "SIGTERM", |
| 1138 | forceKillAfter: PROCESS_TERMINATE_GRACE, |
| 1139 | }, |
| 1140 | ); |
| 1141 | const handle = yield* spawner.spawn(command); |
| 1142 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); |
| 1143 | const exitCode = yield* handle.exitCode; |
| 1144 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); |
| 1145 | const home = decodeUtf8(concatChunks(stdoutBytes)).trim(); |
| 1146 | return home.startsWith("/") ? Option.some(home) : Option.none<string>(); |
| 1147 | }), |
| 1148 | ).pipe( |
| 1149 | Effect.timeoutOption(USER_HOME_TIMEOUT), |
| 1150 | Effect.map(Option.flatten), |
| 1151 | Effect.orElseSucceed(() => Option.none<string>()), |
| 1152 | ); |
| 1153 | |
| 1154 | const makeIsAvailable = ( |
| 1155 | platform: NodeJS.Platform, |
| 1156 | windir: string, |
| 1157 | ): Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path> => |
| 1158 | Effect.gen(function* () { |
| 1159 | if (platform !== "win32") return false; |
| 1160 | const path = yield* Path.Path; |
| 1161 | const fileSystem = yield* FileSystem.FileSystem; |
| 1162 | const wslExePath = path.join(windir, "System32", "wsl.exe"); |
| 1163 | return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); |
| 1164 | }); |
| 1165 | |
| 1166 | export interface DesktopWslEnvironmentTestStub { |
| 1167 | readonly isAvailable?: boolean; |
| 1168 | readonly distros?: ReadonlyArray<WslDistro>; |
| 1169 | readonly distroListError?: DesktopWslDistroListError; |
| 1170 | readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>; |
| 1171 | readonly getUserHome?: (distro: string | null) => Option.Option<string>; |
| 1172 | readonly getDistroIp?: (distro: string | null) => Option.Option<string>; |
| 1173 | readonly prepareRuntime?: ( |
| 1174 | distro: string | null, |
| 1175 | archive: WslRuntimeArchive, |
| 1176 | ) => PrepareWslRuntimeResult; |
| 1177 | readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1178 | readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1179 | // Defaults to success with a plain PATH: a staged runtime that was prepared |
| 1180 | // is assumed to run unless the test says otherwise. |
| 1181 | readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; |
| 1182 | readonly ensureNodePty?: ( |
| 1183 | distro: string | null, |
| 1184 | linuxAppRoot: string, |
| 1185 | options?: EnsureWslNodePtyOptions, |
| 1186 | ) => EnsureWslNodePtyResult; |
| 1187 | } |
| 1188 | |
| 1189 | export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { |
| 1190 | const probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError> = |
| 1191 | stub.distroListError ? Effect.fail(stub.distroListError) : Effect.succeed(stub.distros ?? []); |
| 1192 | return Layer.succeed( |
| 1193 | DesktopWslEnvironment, |
| 1194 | DesktopWslEnvironment.of({ |
| 1195 | isAvailable: Effect.succeed(stub.isAvailable ?? false), |
| 1196 | listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])), |
| 1197 | probeDistros, |
| 1198 | preWarm: () => Effect.void, |
| 1199 | windowsToWslPath: (distro, windowsPath) => |
| 1200 | Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), |
| 1201 | getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()), |
| 1202 | getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>()), |
| 1203 | prepareRuntime: (distro, archive) => |
| 1204 | Effect.succeed( |
| 1205 | stub.prepareRuntime?.(distro, archive) ?? { |
| 1206 | ok: false, |
| 1207 | reason: "prepareRuntime stub not configured", |
| 1208 | }, |
| 1209 | ), |
| 1210 | pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, |
| 1211 | invalidateRuntime: (distro, runtimeId) => |
| 1212 | stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, |
| 1213 | probeRuntime: (distro, linuxAppRoot) => |
| 1214 | Effect.succeed( |
| 1215 | stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, |
| 1216 | ), |
| 1217 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1218 | Effect.succeed( |
| 1219 | stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { |
| 1220 | ok: false, |
| 1221 | reason: "ensureNodePty stub not configured", |
| 1222 | fatal: true, |
| 1223 | }, |
| 1224 | ), |
| 1225 | }), |
| 1226 | ); |
| 1227 | }; |
| 1228 | |
| 1229 | export const layer = Layer.effect( |
| 1230 | DesktopWslEnvironment, |
| 1231 | Effect.gen(function* () { |
| 1232 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 1233 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1234 | const fileSystem = yield* FileSystem.FileSystem; |
| 1235 | const windir = process.env.WINDIR ?? "C:\\Windows"; |
| 1236 | |
| 1237 | const provideSpawner = <A, E>( |
| 1238 | effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>, |
| 1239 | ): Effect.Effect<A, E> => |
| 1240 | effect.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); |
| 1241 | |
| 1242 | // Probe wsl.exe once at layer init and cache the result, exposing |
| 1243 | // `isAvailable` as a resolved value rather than a re-running effect. |
| 1244 | // WSL availability is effectively static for the process lifetime — the |
| 1245 | // Windows feature isn't added/removed mid-session, and backend mode |
| 1246 | // changes already require an app restart — so the cached boolean stays |
| 1247 | // accurate. Crucially this keeps `isAvailable` synchronously resolvable: |
| 1248 | // it's read inside the sync IPC handler getLocalEnvironmentBootstraps |
| 1249 | // (via the primary instance's lazy label -> resolvePrimaryLabel -> |
| 1250 | // describePrimary). The underlying probe does a filesystem `exists` |
| 1251 | // check, so leaving it as a live effect would make Effect.runSync throw |
| 1252 | // there and break the renderer's synchronous bootstrap path. |
| 1253 | const wslAvailable = yield* makeIsAvailable(environment.platform, windir).pipe( |
| 1254 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 1255 | Effect.provideService(Path.Path, environment.path), |
| 1256 | Effect.withSpan("desktop.wsl.isAvailable"), |
| 1257 | ); |
| 1258 | const isAvailable = Effect.succeed(wslAvailable); |
| 1259 | |
| 1260 | const windowsToWslPath = (distro: string | null, windowsPath: string) => |
| 1261 | provideSpawner(windowsToWslPathImpl(distro, windowsPath)).pipe( |
| 1262 | Effect.withSpan("desktop.wsl.windowsToWslPath"), |
| 1263 | ); |
| 1264 | |
| 1265 | // Cache user-home results per distro key — folder picker can be opened |
| 1266 | // many times in a session and the value is stable for the life of the |
| 1267 | // distro. Negative results aren't cached so a transient wsl.exe failure |
| 1268 | // doesn't permanently disable tilde expansion. |
| 1269 | const userHomeCache = new Map<string, string>(); |
| 1270 | const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) { |
| 1271 | const key = distro ?? "__default__"; |
| 1272 | const cached = userHomeCache.get(key); |
| 1273 | if (cached !== undefined) return Option.some(cached); |
| 1274 | const resolved = yield* provideSpawner(getUserHomeImpl(distro)); |
| 1275 | if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); |
| 1276 | return resolved; |
| 1277 | }); |
| 1278 | |
| 1279 | const getDistroIp = (distro: string | null) => |
| 1280 | provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); |
| 1281 | |
| 1282 | const probeDistros = provideSpawner(probeWslDistros).pipe( |
| 1283 | Effect.withSpan("desktop.wsl.probeDistros"), |
| 1284 | ); |
| 1285 | |
| 1286 | return DesktopWslEnvironment.of({ |
| 1287 | isAvailable, |
| 1288 | listDistros: probeDistros.pipe( |
| 1289 | Effect.orElseSucceed(() => []), |
| 1290 | Effect.withSpan("desktop.wsl.listDistros"), |
| 1291 | ), |
| 1292 | probeDistros, |
| 1293 | preWarm: (distro) => |
| 1294 | provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), |
| 1295 | windowsToWslPath, |
| 1296 | getUserHome, |
| 1297 | getDistroIp, |
| 1298 | prepareRuntime: (distro, archive) => |
| 1299 | provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( |
| 1300 | Effect.withSpan("desktop.wsl.prepareRuntime"), |
| 1301 | ), |
| 1302 | pruneRuntimes: (distro, runtimeId) => |
| 1303 | provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( |
| 1304 | Effect.withSpan("desktop.wsl.pruneRuntimes"), |
| 1305 | ), |
| 1306 | invalidateRuntime: (distro, runtimeId) => |
| 1307 | provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( |
| 1308 | Effect.withSpan("desktop.wsl.invalidateRuntime"), |
| 1309 | ), |
| 1310 | probeRuntime: (distro, linuxAppRoot) => |
| 1311 | provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( |
| 1312 | Effect.withSpan("desktop.wsl.probeRuntime"), |
| 1313 | ), |
| 1314 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1315 | provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( |
| 1316 | Effect.withSpan("desktop.wsl.ensureNodePty"), |
| 1317 | ), |
| 1318 | }); |
| 1319 | }), |
| 1320 | ); |