1// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell.
2import { describe, it } from "@effect/vitest";
3import { afterAll, expect } from "vite-plus/test";
4import * as NodeChildProcess from "node:child_process";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11import * as TestClock from "effect/testing/TestClock";
12import { ChildProcessSpawner } from "effect/process";
13
14import {
15 buildWslRuntimeInstallScript,
16 buildWslRuntimeInvalidateScript,
17 buildWslRuntimePruneScript,
18 buildWslRuntimeProbeScript,
19 DesktopWslDistroListError,
20 formatMissingToolsReason,
21 parseNodePath,
22 parseNodeVersion,
23 parseResolvedPath,
24 parseToolchainReport,
25 parseWslRuntimeRoot,
26 probeWslDistros,
27} from "./DesktopWslEnvironment.ts";
28
29const encoder = new TextEncoder();
30
31// The install script only fails the way this file cares about when a real shell
32// runs it, so find one that has the tools it needs: bash directly on Linux, and
33// the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere
34// else the executed suite skips and the generated-text assertions stand alone.
35const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const;
36
37const posixShellRunner = (() => {
38 // Candidates rather than a platform switch: wsl.exe simply fails to spawn
39 // where it does not exist, which is the same answer as a shell missing flock.
40 const candidates = [
41 { file: "bash", args: [] as ReadonlyArray<string> },
42 { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray<string> },
43 ];
44 const probe = [
45 "[ -d /proc/1 ] || exit 1",
46 ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`),
47 ].join("\n");
48 return (
49 candidates.find((candidate) => {
50 const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], {
51 encoding: "utf8",
52 });
53 return result.status === 0;
54 }) ?? null
55 );
56})();
57
58const runShell = (script: string) => {
59 if (posixShellRunner === null) throw new Error("no POSIX shell runner available");
60 // The install script arrives on stdin in production too, which is what lets
61 // its own /proc scan not match itself.
62 const result = NodeChildProcess.spawnSync(
63 posixShellRunner.file,
64 [...posixShellRunner.args, "-s"],
65 { input: script, encoding: "utf8" },
66 );
67 return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
68};
69
70const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
71
72// Polls for up to five seconds until a background holder has taken effect.
73const awaitHolder = (condition: string) =>
74 `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`;
75
76// Holds a cache busy the way a running server does: the holder's argv[0] is the
77// runtime entry, which the prune and install scripts look for in /proc cmdline.
78// It reads a pipe the script keeps open on fd 7, so it exits with the script
79// instead of outliving it on a timer.
80const holdRuntimeBusy = (entry: string) =>
81 [
82 `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`,
83 awaitHolder(`grep -qF -- ${entry} /proc/[0-9]*/cmdline 2>/dev/null`),
84 ].join("\n");
85
86// Holds a cache's install lock the way a concurrent install does, and likewise
87// releases it when the script exits.
88const holdInstallLock = (lock: string) =>
89 [
90 `exec 6> >(exec 9> ${lock}; flock -x 9; cat >/dev/null 2>&1)`,
91 awaitHolder(`! flock -n ${lock} true`),
92 ].join("\n");
93
94const readField = (stdout: string, field: string) => {
95 const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`));
96 if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`);
97 return line.slice(field.length + 1).trim();
98};
99
100// Stands in for the release's self-contained `t3` executable: the install
101// script only asks it for `--version`.
102const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n';
103
104const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
105 ChildProcessSpawner.make(() =>
106 Effect.succeed(
107 ChildProcessSpawner.makeHandle({
108 pid: ChildProcessSpawner.ProcessId(1),
109 exitCode:
110 result.exitCode === undefined
111 ? Effect.never
112 : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
113 isRunning: Effect.succeed(result.exitCode === undefined),
114 kill: () => Effect.void,
115 unref: Effect.succeed(Effect.void),
116 stdin: Sink.drain,
117 stdout: Stream.make(encoder.encode(result.stdout ?? "")),
118 stderr: Stream.empty,
119 all: Stream.empty,
120 getInputFd: () => Sink.drain,
121 getOutputFd: () => Stream.empty,
122 }),
123 ),
124 );
125
126describe("probeWslDistros", () => {
127 it.effect("preserves a successful empty distro list", () =>
128 Effect.gen(function* () {
129 const distros = yield* probeWslDistros;
130 expect(distros).toEqual([]);
131 }).pipe(
132 Effect.provideService(
133 ChildProcessSpawner.ChildProcessSpawner,
134 makeDistroListSpawner({ stdout: "", exitCode: 0 }),
135 ),
136 ),
137 );
138
139 it.effect("fails when the distro-list command exits unsuccessfully", () =>
140 Effect.gen(function* () {
141 const error = yield* probeWslDistros.pipe(Effect.flip);
142 expect(error).toBeInstanceOf(DesktopWslDistroListError);
143 expect(error.message).toContain("exited with code 1");
144 }).pipe(
145 Effect.provideService(
146 ChildProcessSpawner.ChildProcessSpawner,
147 makeDistroListSpawner({ exitCode: 1 }),
148 ),
149 ),
150 );
151
152 it.effect("fails when the distro-list command times out", () => {
153 const layer = Layer.merge(
154 TestClock.layer(),
155 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})),
156 );
157 return Effect.gen(function* () {
158 const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped);
159 yield* Effect.yieldNow;
160 yield* TestClock.adjust(Duration.seconds(8));
161 const error = yield* Fiber.join(fiber);
162 expect(error).toBeInstanceOf(DesktopWslDistroListError);
163 expect(error.message).toContain("timed out");
164 }).pipe(Effect.provide(layer));
165 });
166});
167
168describe("WSL runtime cache", () => {
169 it.each([
170 [
171 "install",
172 (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)),
173 ],
174 ["prune", buildWslRuntimePruneScript],
175 ["invalidate", buildWslRuntimeInvalidateScript],
176 ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => {
177 const runtimeId = "1.2.3/x64; touch /tmp/nope";
178 const script = buildScript(runtimeId);
179 expect(script).toContain("/1.2.3_x64__touch__tmp_nope");
180 expect(script).not.toContain(runtimeId);
181 });
182
183 it("installs through a temporary directory and only reuses valid completed caches", () => {
184 const script = buildWslRuntimeInstallScript(
185 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
186 "1.2.3-x64",
187 "b".repeat(64),
188 );
189
190 expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');
191 expect(script).toContain(' [ -f "$ready_marker" ] &&');
192 expect(script).toContain(' runtime_entry_runs "$runtime_root" &&');
193 expect(script).toContain("if runtime_is_ready; then");
194 expect(script).not.toContain("bin.mjs");
195 expect(script).not.toContain("node-pty");
196 expect(script).toContain("trap 'exit 1' HUP INT TERM");
197 expect(script).toContain('exec 9> "$runtime_lock"');
198 expect(script).toContain("flock -x 9");
199 expect(script).not.toContain('rm -rf "$runtime_lock"');
200 expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');
201 expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"');
202 // The release archive wraps everything in one `t3-<version>-linux-x64/`
203 // directory; stripping it puts the executable at `$runtime_root/t3`.
204 expect(script).toContain(
205 "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1",
206 );
207 expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then');
208 expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');
209 expect(script).not.toContain('rm -rf "$runtime_root"');
210
211 const lockAcquired = script.indexOf("flock -x 9");
212 const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1);
213 const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
214 expect(lockAcquired).toBeGreaterThan(-1);
215 expect(readinessAfterLock).toBeGreaterThan(lockAcquired);
216 expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock);
217 });
218
219 it("verifies the archive digest before extracting, and only on a cache miss", () => {
220 const script = buildWslRuntimeInstallScript(
221 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
222 "1.2.3-x64",
223 "b".repeat(64),
224 );
225
226 const expected = "b".repeat(64);
227 expect(script).toContain(
228 "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)",
229 );
230 expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`);
231
232 // A warm cache exits before the hash, so reuse never pays for it, and the
233 // mismatch check runs before anything mutates the cache.
234 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
235 const digestChecked = script.indexOf("archive_sha=$(sha256sum");
236 const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
237 const extracted = script.indexOf("tar -xzf");
238 expect(digestChecked).toBeGreaterThan(readyShortCircuit);
239 expect(existingRuntimeMoved).toBeGreaterThan(digestChecked);
240 expect(extracted).toBeGreaterThan(digestChecked);
241 });
242
243 // Invalidation revokes the ready marker without stopping the backend that
244 // failed the probe, so the next install can find an unready tree that a live
245 // process is still running out of. Deleting it there unlinks node_modules
246 // under that process; the pruner already refuses to touch in-use caches, and
247 // the install path has to refuse too.
248 it("moves an in-use runtime aside instead of deleting it under a live backend", () => {
249 const script = buildWslRuntimeInstallScript(
250 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
251 "sha256-" + "c".repeat(64),
252 "b".repeat(64),
253 );
254
255 expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
256 // No /proc means no way to tell, and guessing wrong costs a backend its
257 // runtime, so an unknowable answer has to count as in use.
258 expect(script).toContain(" [ -d /proc/1 ] || return 0");
259 expect(script).toContain(' if runtime_in_use "$runtime_root"; then');
260
261 // A process's cmdline keeps the pre-rename path, so the question is only
262 // answerable before the move.
263 const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then');
264 const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
265 expect(inUseChecked).toBeGreaterThan(-1);
266 expect(inUseChecked).toBeLessThan(moved);
267
268 // In use: keep the tree and restart the sweep's clock, because renaming
269 // preserves the directory's mtime and a long-installed tree would otherwise
270 // already be past the age gate. Idle: delete it now, as before.
271 const kept = script.indexOf('touch "$runtime_stale"');
272 const deleted = script.indexOf('rm -rf "$runtime_stale"');
273 expect(kept).toBeGreaterThan(moved);
274 expect(deleted).toBeGreaterThan(kept);
275 });
276
277 it("treats a runtime whose executable no longer runs as a cache miss", () => {
278 const script = buildWslRuntimeInstallScript(
279 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
280 "1.2.3-x64",
281 "b".repeat(64),
282 );
283
284 // The same proof the SSH runner and CLI installers use: executable, and
285 // `--version` exits 0. That is what decides arch and loadability, so no
286 // separate native probe is needed.
287 expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1');
288
289 // Readiness gates the short-circuit, so a cache whose executable broke
290 // reinstalls from the archive instead of being reused forever.
291 const entryCheckDefined = script.indexOf("runtime_entry_runs() {");
292 const readinessDefined = script.indexOf("runtime_is_ready() {");
293 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
294 expect(entryCheckDefined).toBeGreaterThan(-1);
295 expect(entryCheckDefined).toBeLessThan(readinessDefined);
296 expect(readinessDefined).toBeLessThan(readyShortCircuit);
297 });
298
299 // A swapped or half-written `t3` can still exist and even still answer
300 // `--version`, and launch then runs something this install never verified.
301 // The digest the install records is what turns that into a miss.
302 it("re-hashes the executable against the digest the install recorded", () => {
303 const script = buildWslRuntimeInstallScript(
304 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
305 "1.2.3-x64",
306 "b".repeat(64),
307 );
308
309 expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`);
310 expect(script).toContain(
311 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
312 );
313 // A runtime installed before the marker carried a digest reads as empty,
314 // which has to be a miss rather than a pass.
315 expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&');
316 expect(script).toContain(
317 `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`,
318 );
319
320 // The digest is recorded after extraction and before promotion.
321 const extracted = script.indexOf("tar -xzf");
322 const digestRecorded = script.indexOf(
323 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
324 );
325 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
326 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
327 expect(digestRecorded).toBeGreaterThan(extracted);
328 expect(markerWritten).toBeGreaterThan(digestRecorded);
329 expect(promoted).toBeGreaterThan(markerWritten);
330 });
331
332 it("refuses to mark an archive whose executable does not run as ready", () => {
333 const script = buildWslRuntimeInstallScript(
334 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
335 "1.2.3-x64",
336 "b".repeat(64),
337 );
338
339 expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then');
340
341 // The extracted tree is rejected before the ready marker is written, so a
342 // defective archive falls back to the mounted tree instead of caching.
343 const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"');
344 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
345 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
346 expect(payloadValidated).toBeGreaterThan(-1);
347 expect(markerWritten).toBeGreaterThan(payloadValidated);
348 expect(promoted).toBeGreaterThan(payloadValidated);
349 });
350
351 it("parses only absolute Linux runtime paths", () => {
352 expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe(
353 "/home/josh/.t3/wsl-runtime/1.2.3-x64",
354 );
355 expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull();
356 expect(parseWslRuntimeRoot("noise\n")).toBeNull();
357 });
358
359 it("prunes completed runtimes except the current and newest previous cache", () => {
360 const script = buildWslRuntimePruneScript("1.2.3/x64");
361
362 expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"');
363 expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]');
364 expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue');
365 expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue');
366 expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue');
367 expect(script).toContain('rm -rf -- "$candidate"');
368 });
369
370 it("never deletes a runtime another backend is running from", () => {
371 const script = buildWslRuntimePruneScript("1.2.3/x64");
372
373 // The running backend's argv holds `<runtime>/t3`, so
374 // the process itself is the lease and exiting releases it. Nothing has to be
375 // registered up front, which is what makes this cover backends already
376 // running from an older version that knows nothing about pruning.
377 expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
378 expect(script).toContain(' ! runtime_in_use "$candidate" || continue');
379
380 // Without visible processes the retention rules cannot tell a live cache
381 // from an abandoned one, so the sweep is skipped rather than guessed at.
382 expect(script).toContain("[ -d /proc/1 ] || exit 0");
383
384 // The guard has to gate the delete, not just exist.
385 const inUseChecked = script.indexOf('! runtime_in_use "$candidate"');
386 const removed = script.indexOf('rm -rf -- "$candidate"');
387 expect(inUseChecked).toBeGreaterThan(-1);
388 expect(removed).toBeGreaterThan(inUseChecked);
389 });
390
391 it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => {
392 const script = buildWslRuntimePruneScript("1.2.3/x64");
393
394 // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry
395 // no ready marker either; without this pass a killed install leaks forever.
396 expect(script).toContain(
397 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
398 );
399 // Age guard: a scratch directory younger than this belongs to a live install.
400 expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120');
401 });
402
403 it("invalidates a cache by dropping its ready marker, not the tree", () => {
404 const script = buildWslRuntimeInvalidateScript("1.2.3/x64");
405
406 // Readiness is a presence check, so a tree whose pty.node is present but
407 // unloadable stays ready forever unless the probe can revoke the marker.
408 expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"');
409 // Deleting the tree here would pull it out from under any backend still
410 // running from it; the next install moves an unready root aside instead.
411 expect(script).not.toContain("rm -rf");
412 });
413});
414
415// Reading the generated script proves what it says, not what it does. A cache
416// whose entry was truncated satisfied every assertion above and still got
417// reused, so these run the real script against a real archive in a throwaway
418// HOME and check the outcome.
419describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {
420 const fixtures: Array<string> = [];
421
422 afterAll(() => {
423 for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`);
424 fixtures.length = 0;
425 });
426
427 const createFixture = () => {
428 const result = runShell(
429 [
430 "set -eu",
431 "work=$(mktemp -d)",
432 // Mirrors the release archive: one top-level versioned directory that
433 // holds the executable and its native addons.
434 'stage="$work/stage/t3-0.0.0-linux-x64"',
435 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"',
436 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`,
437 'chmod +x "$stage/t3"',
438 `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`,
439 `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`,
440 `printf 'work:%s\\n' "$work"`,
441 `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,
442 ].join("\n"),
443 );
444 expect(result.status, result.stderr).toBe(0);
445
446 const work = readField(result.stdout, "work");
447 fixtures.push(work);
448 const archivePath = `${work}/wsl-runtime.tar.gz`;
449 const archiveSha = readField(result.stdout, "archiveSha");
450 const runtimeId = `sha256-${archiveSha}`;
451 // The script reads $HOME, and WSL does not inherit the parent process's
452 // environment, so the home override rides in the script itself.
453 const installScript = (archive = archivePath, sha = archiveSha) =>
454 [
455 `HOME=${sh(`${work}/home`)}`,
456 "export HOME",
457 buildWslRuntimeInstallScript(archive, runtimeId, sha),
458 ].join("\n");
459 return {
460 work,
461 archivePath,
462 archiveSha,
463 runtimeId,
464 runtimeParent: `${work}/home/.t3/wsl-runtime`,
465 runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`,
466 serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`,
467 installScript,
468 install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),
469 };
470 };
471
472 const probeFixture = (fixture: ReturnType<typeof createFixture>) =>
473 runShell(
474 [
475 `export HOME=${sh(`${fixture.work}/home`)}`,
476 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"',
477 // Isolate login profiles and hide the host's Node/version managers.
478 // The resolver must discover the fixture's installation itself.
479 "bash() { (",
480 " command() {",
481 ' case "$*" in',
482 ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;',
483 ' *) builtin command "$@" ;;',
484 " esac",
485 " }",
486 ' eval "$2"',
487 "); }",
488 buildWslRuntimeProbeScript(fixture.runtimeRoot),
489 ].join("\n"),
490 );
491
492 it("discovers version-managed Node for providers with a standalone runtime", () => {
493 const fixture = createFixture();
494 expect(fixture.install().status).toBe(0);
495 const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`;
496 const setup = runShell(
497 [
498 "set -eu",
499 `mkdir -p ${sh(nodeBin)}`,
500 `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`,
501 `chmod +x ${sh(`${nodeBin}/node`)}`,
502 ].join("\n"),
503 );
504 expect(setup.status, setup.stderr).toBe(0);
505
506 const probe = probeFixture(fixture);
507
508 expect(probe.status, probe.stderr).toBe(0);
509 const resolvedPath = parseResolvedPath(probe.stdout);
510 expect(resolvedPath?.split(":")).toContain(nodeBin);
511 const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`);
512 expect(provider.status, provider.stderr).toBe(0);
513 expect(provider.stdout).toBe("linux-node-provider\n");
514 });
515
516 it("keeps standalone runtime readiness independent of Node availability", () => {
517 const fixture = createFixture();
518 expect(fixture.install().status).toBe(0);
519
520 const probe = probeFixture(fixture);
521
522 expect(probe.status, probe.stderr).toBe(0);
523 expect(parseResolvedPath(probe.stdout)).not.toBeNull();
524 });
525
526 it("keeps the inherited PATH when bash is unavailable", () => {
527 const fixture = createFixture();
528 expect(fixture.install().status).toBe(0);
529 const probe = runShell(
530 [
531 "bash() { return 127; }",
532 'export PATH="/fixture/bin:/usr/bin:/bin"',
533 buildWslRuntimeProbeScript(fixture.runtimeRoot),
534 ].join("\n"),
535 );
536
537 expect(probe.status, probe.stderr).toBe(0);
538 expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin");
539 });
540
541 it("reuses a warm cache without touching the archive", () => {
542 const fixture = createFixture();
543 expect(fixture.install().status).toBe(0);
544 // Deleting the archive is how the test tells reuse apart from a silent
545 // reinstall: only the warm path can succeed without it.
546 expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
547
548 const warm = fixture.install();
549
550 expect(warm.status, warm.stderr).toBe(0);
551 expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);
552 });
553
554 it("reinstalls a cache whose executable was truncated", () => {
555 const fixture = createFixture();
556 expect(fixture.install().status).toBe(0);
557 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
558
559 const repaired = fixture.install();
560
561 expect(repaired.status, repaired.stderr).toBe(0);
562 expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot);
563 const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`);
564 expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE);
565 });
566
567 it("falls back instead of launching a corrupted cache it cannot reinstall", () => {
568 const fixture = createFixture();
569 expect(fixture.install().status).toBe(0);
570 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
571 expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
572
573 const broken = fixture.install();
574
575 // Non-zero with no runtimeRoot is what sends the backend to the mounted
576 // server tree. Exiting 0 here is the bug: launch would pick the zero-byte
577 // server, fail to become ready, and do it again on every restart.
578 expect(broken.status).not.toBe(0);
579 expect(parseWslRuntimeRoot(broken.stdout)).toBeNull();
580 });
581
582 it("extracts once when two installs race for the same cache", () => {
583 const fixture = createFixture();
584 // A tar shim counts extractions and holds the critical section open long
585 // enough that the second install is certain to arrive while the first is
586 // still inside it. One extraction is the answer either way the runs
587 // interleave: whoever waits for the lock re-checks readiness before
588 // spending an extract, so a broken lock shows up as two.
589 const raced = runShell(
590 [
591 "set -eu",
592 `work=${sh(fixture.work)}`,
593 'mkdir -p "$work/bin"',
594 "real_tar=$(command -v tar)",
595 `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`,
596 'chmod +x "$work/bin/tar"',
597 ': > "$work/tar-calls"',
598 'PATH="$work/bin:$PATH"',
599 "export PATH",
600 `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`,
601 fixture.installScript(),
602 "T3CODE_INSTALL_SCRIPT",
603 // Both racers run the same file, and neither file path contains the
604 // runtime root, so the script's own /proc scan cannot see them.
605 'sh "$work/install.sh" > "$work/first.out" 2>&1 &',
606 "first=$!",
607 'sh "$work/install.sh" > "$work/second.out" 2>&1 &',
608 "second=$!",
609 "if wait $first; then first_status=0; else first_status=$?; fi",
610 "if wait $second; then second_status=0; else second_status=$?; fi",
611 `printf 'firstStatus:%s\\n' "$first_status"`,
612 `printf 'secondStatus:%s\\n' "$second_status"`,
613 `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`,
614 `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`,
615 `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`,
616 ].join("\n"),
617 );
618
619 expect(raced.status, raced.stderr).toBe(0);
620 expect(readField(raced.stdout, "firstStatus")).toBe("0");
621 expect(readField(raced.stdout, "secondStatus")).toBe("0");
622 expect(readField(raced.stdout, "extractions")).toBe("1");
623 expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot);
624 expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot);
625 });
626
627 it("leaves no half-built cache when extraction fails", () => {
628 const fixture = createFixture();
629 // Truncating the archive and re-recording its digest gets the install past
630 // the digest gate and into a tar that dies mid-stream, which is what a full
631 // disk or an interrupted write looks like from inside the distro.
632 const truncated = runShell(
633 [
634 "set -eu",
635 `work=${sh(fixture.work)}`,
636 'size=$(wc -c < "$work/wsl-runtime.tar.gz")',
637 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"',
638 `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`,
639 ].join("\n"),
640 );
641 expect(truncated.status, truncated.stderr).toBe(0);
642
643 const failed = fixture.install(
644 `${fixture.work}/truncated.tar.gz`,
645 readField(truncated.stdout, "sha"),
646 );
647
648 expect(failed.status).not.toBe(0);
649 expect(parseWslRuntimeRoot(failed.stdout)).toBeNull();
650 // A partial extract that survived under the cache name would be promoted by
651 // the next launch's readiness check; scratch that survived would sit there
652 // until the pruner's age sweep. Neither is left behind. Only directories
653 // are counted: the empty flock file stays on purpose, which is what keeps
654 // the lock from carrying stale state across a killed install.
655 const leftovers = runShell(
656 `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
657 );
658 expect(leftovers.stdout.trim()).toBe("");
659 });
660
661 // The archive and the identity recorded beside it can diverge — a partial
662 // download, or a rebuilt archive dropped next to an older sidecar. Either
663 // gate firing means the bytes never reach the cache under a name that claims
664 // to describe something else.
665 it("refuses an archive whose bytes do not match the digest recorded for it", () => {
666 const fixture = createFixture();
667
668 const refused = fixture.install(fixture.archivePath, "e".repeat(64));
669
670 expect(refused.status).not.toBe(0);
671 expect(refused.stderr).toContain("does not match its recorded SHA-256");
672 expect(parseWslRuntimeRoot(refused.stdout)).toBeNull();
673 const leftovers = runShell(
674 `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
675 );
676 expect(leftovers.stdout.trim()).toBe("");
677 });
678
679 it("leases a warm cache across the prepare-to-spawn handoff", () => {
680 const fixture = createFixture();
681 expect(fixture.install().status).toBe(0);
682 const selected = runShell(
683 [
684 "set -eu",
685 `runtime_parent=${sh(fixture.runtimeParent)}`,
686 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
687 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
688 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
689 `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
690 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
691 `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`,
692 fixture.installScript(),
693 "T3CODE_SELECT_SCRIPT",
694 `sh ${sh(`${fixture.work}/select.sh`)}`,
695 `HOME=${sh(`${fixture.work}/home`)}`,
696 "export HOME",
697 buildWslRuntimePruneScript("sha256-current"),
698 `test -d ${sh(fixture.runtimeRoot)}`,
699 ].join("\n"),
700 );
701
702 expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0);
703 });
704
705 it("prunes a selected cache after its prepare-to-spawn grace period expires", () => {
706 const fixture = createFixture();
707 expect(fixture.install().status).toBe(0);
708 const result = runShell(
709 [
710 "set -eu",
711 `runtime_parent=${sh(fixture.runtimeParent)}`,
712 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
713 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
714 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
715 `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
716 `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`,
717 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
718 `HOME=${sh(`${fixture.work}/home`)}`,
719 "export HOME",
720 buildWslRuntimePruneScript("sha256-current"),
721 `test ! -e ${sh(fixture.runtimeRoot)}`,
722 ].join("\n"),
723 );
724
725 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
726 });
727
728 it("removes an aged stale tree after replacing an active unready cache", () => {
729 const fixture = createFixture();
730 expect(fixture.install().status).toBe(0);
731 const result = runShell(
732 [
733 "set -eu",
734 `runtime_root=${sh(fixture.runtimeRoot)}`,
735 `runtime_parent=${sh(fixture.runtimeParent)}`,
736 'rm "$runtime_root/.t3code-wsl-runtime-ready"',
737 holdRuntimeBusy('"$runtime_root/t3"'),
738 fixture.installScript(),
739 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)',
740 'test -n "$stale"',
741 'touch -d "180 minutes ago" "$stale"',
742 `HOME=${sh(`${fixture.work}/home`)}`,
743 "export HOME",
744 buildWslRuntimePruneScript(fixture.runtimeId),
745 'test ! -e "$stale"',
746 ].join("\n"),
747 );
748
749 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
750 });
751
752 it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => {
753 const result = runShell(
754 [
755 "set -eu",
756 "work=$(mktemp -d)",
757 'home="$work/home"',
758 'runtime_parent="$home/.t3/wsl-runtime"',
759 'mkdir -p "$runtime_parent"',
760 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }',
761 "make_ready sha256-current",
762 "make_ready sha256-previous",
763 "make_ready sha256-active",
764 "make_ready sha256-old",
765 "make_ready sha256-locked",
766 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"',
767 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
768 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',
769 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',
770 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"',
771 holdRuntimeBusy('"$runtime_parent/sha256-active/t3"'),
772 holdInstallLock('"$runtime_parent/.sha256-locked.install.lock"'),
773 `HOME="$home"`,
774 "export HOME",
775 buildWslRuntimePruneScript("sha256-current"),
776 'test -d "$runtime_parent/sha256-current"',
777 'test -d "$runtime_parent/sha256-previous"',
778 'test -d "$runtime_parent/sha256-active"',
779 'test -d "$runtime_parent/sha256-locked"',
780 'test -d "$runtime_parent/versions"',
781 'test ! -e "$runtime_parent/sha256-old"',
782 'test ! -e "$runtime_parent/sha256-markerless"',
783 'rm -rf "$work"',
784 ].join("\n"),
785 );
786
787 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
788 });
789});
790
791describe("parseToolchainReport", () => {
792 it("returns no missing tools and no node version on empty output", () => {
793 expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });
794 });
795
796 it("collects all missing: lines", () => {
797 const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n");
798 expect(parseToolchainReport(stdout)).toEqual({
799 missingTools: ["make", "g++"],
800 nodeVersion: "24.10.0",
801 });
802 });
803
804 it("ignores blank lines and trims whitespace", () => {
805 const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n");
806 expect(parseToolchainReport(stdout)).toEqual({
807 missingTools: ["python3"],
808 nodeVersion: "v22.16.0",
809 });
810 });
811
812 it("returns null node version when value after prefix is empty", () => {
813 expect(parseToolchainReport("nodeVersion:")).toEqual({
814 missingTools: [],
815 nodeVersion: null,
816 });
817 });
818});
819
820describe("parseNodePath", () => {
821 it("extracts the absolute node path from a nodePath: line", () => {
822 const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node";
823 expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node");
824 });
825
826 it("returns null when node was not found (empty value after prefix)", () => {
827 expect(parseNodePath("nodePath:")).toBeNull();
828 });
829
830 it("returns null when there is no nodePath line at all", () => {
831 expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull();
832 });
833
834 it("ignores surrounding noise and trims whitespace", () => {
835 const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n");
836 expect(parseNodePath(stdout)).toBe("/usr/bin/node");
837 });
838});
839
840describe("parseNodeVersion", () => {
841 it("extracts the node version from a nodeVersion: line", () => {
842 expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0");
843 });
844
845 it("returns null when the version value is empty", () => {
846 expect(parseNodeVersion("nodeVersion:")).toBeNull();
847 });
848
849 it("returns null when there is no nodeVersion line at all", () => {
850 expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull();
851 });
852
853 it("ignores surrounding noise and trims whitespace", () => {
854 const stdout = [
855 "some preamble noise",
856 " nodeVersion:22.16.0 ",
857 "nodePath:/usr/bin/node",
858 ].join("\n");
859 expect(parseNodeVersion(stdout)).toBe("22.16.0");
860 });
861});
862
863describe("parseResolvedPath", () => {
864 it("preserves spaces and apostrophes in the resolved login-shell PATH", () => {
865 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
866 expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe(
867 resolvedPath,
868 );
869 });
870
871 it("accepts CRLF output without retaining the carriage return", () => {
872 expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe(
873 "/usr/local/bin:/usr/bin",
874 );
875 });
876
877 it("returns null when the resolved PATH is absent or empty", () => {
878 expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull();
879 expect(parseResolvedPath("resolvedPath:\n")).toBeNull();
880 });
881});
882
883describe("formatMissingToolsReason", () => {
884 it("returns null when everything is present and node is in range", () => {
885 expect(
886 formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"),
887 ).toBeNull();
888 });
889
890 it("returns null when range is not specified and tools are present", () => {
891 expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull();
892 });
893
894 it("flags missing node first", () => {
895 const reason = formatMissingToolsReason(
896 { missingTools: ["node", "make"], nodeVersion: null },
897 "^24.10",
898 );
899 expect(reason).toContain("node");
900 expect(reason).toContain("^24.10");
901 expect(reason).toContain("make");
902 expect(reason).toContain("nvm");
903 });
904
905 it("flags an out-of-range node version with the actual version surfaced", () => {
906 const reason = formatMissingToolsReason(
907 { missingTools: [], nodeVersion: "20.0.0" },
908 "^24.10 || ^22.16",
909 );
910 expect(reason).toContain("node 20.0.0");
911 expect(reason).toContain("requires ^24.10 || ^22.16");
912 });
913
914 it("flags missing build tools without node when node is fine", () => {
915 const reason = formatMissingToolsReason(
916 { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" },
917 "^24.10",
918 );
919 expect(reason).toContain("g++");
920 expect(reason).toContain("python3");
921 expect(reason).toContain("build-essential");
922 expect(reason).not.toContain("nvm");
923 });
924});