diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a8..340c140a1657 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -176,8 +176,21 @@ describe("DesktopBackendConfiguration", () => { const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); + const archiveHash = "a".repeat(64); + yield* fileSystem.writeFileString( + path.join(baseDir, "wsl-runtime.tar.gz.sha256"), + archiveHash, + ); const observedDistros: Array = []; + const observedWindowsConversions: string[] = []; + const observedRuntimePreparations: Array<{ + readonly distro: string | null; + readonly windowsArchivePath: string; + readonly archiveHash: string; + }> = []; + const observedNodePtyRoots: string[] = []; + const linuxRepoRoot = `/home/test/.cache/t3code/desktop-wsl-runtime/sha256-${archiveHash}`; const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; return yield* configuration.resolveWsl({ port: 5000, distro: null }); @@ -193,12 +206,22 @@ describe("DesktopBackendConfiguration", () => { { name: "Debian", isDefault: false, version: 2 }, { name: "Ubuntu", isDefault: true, version: 2 }, ], - windowsToWslPath: (distro) => { + windowsToWslPath: (_distro, windowsPath) => { + observedWindowsConversions.push(windowsPath); + return Option.some("/mnt/c/t3code"); + }, + preparePackagedRuntime: (distro, windowsArchivePath, preparedArchiveHash) => { observedDistros.push(distro); - return Option.some("/repo/apps/server/dist/bin.mjs"); + observedRuntimePreparations.push({ + distro, + windowsArchivePath, + archiveHash: preparedArchiveHash, + }); + return { ok: true, linuxRepoRoot }; }, - ensureNodePty: (distro) => { + ensureNodePty: (distro, root) => { observedDistros.push(distro); + observedNodePtyRoots.push(root); return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; }, getDistroIp: (distro) => { @@ -221,6 +244,73 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.runningDistro, "Ubuntu"); assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); + assert.deepEqual(observedWindowsConversions, []); + assert.deepEqual(observedRuntimePreparations, [ + { + distro: "Ubuntu", + windowsArchivePath: path.join(baseDir, "wsl-runtime.tar.gz"), + archiveHash, + }, + ]); + assert.deepEqual(observedNodePtyRoots, [linuxRepoRoot]); + assert.include(config.args, `${linuxRepoRoot}/apps/server/dist/bin.mjs`); + assert.isTrue(Option.isNone(config.preflightFailure)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl falls back to the mounted runtime when native staging fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fileSystem.writeFileString(entryPath, ""); + yield* fileSystem.writeFileString( + path.join(baseDir, "wsl-runtime.tar.gz.sha256"), + `${"b".repeat(64)}\n`, + ); + + const observedNodePtyRoots: string[] = []; + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolveWsl({ port: 5001, distro: "Ubuntu" }); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + preparePackagedRuntime: () => ({ + ok: false, + reason: "archive extraction failed", + }), + windowsToWslPath: () => Option.some("/mnt/c/t3code"), + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + getDistroIp: () => Option.none(), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + + assert.deepEqual(observedNodePtyRoots, ["/mnt/c/t3code"]); + assert.include(config.args, "/mnt/c/t3code/apps/server/dist/bin.mjs"); assert.isTrue(Option.isNone(config.preflightFailure)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -234,12 +324,14 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const dirname = path.join(baseDir, "apps/desktop/src"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node"; - const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs"; + const linuxRepoRoot = "/tmp/t3 code's launch"; + const linuxEntryPath = `${linuxRepoRoot}/apps/server/dist/bin.mjs`; const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world"; const config = yield* Effect.gen(function* () { @@ -254,18 +346,17 @@ describe("DesktopBackendConfiguration", () => { DesktopWslEnvironment.layerTest({ isAvailable: true, distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - windowsToWslPath: () => Option.some(linuxEntryPath), + windowsToWslPath: () => Option.some(linuxRepoRoot), ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), getDistroIp: () => Option.some("172.27.0.99"), }), ), Layer.provideMerge( makeEnvironmentLayer(baseDir, { - appPath: baseDir, + dirname, devServerUrl, - isPackaged: true, + isPackaged: false, platform: "win32", - resourcesPath: baseDir, }), ), ), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e55..58ccc621c5d5 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -19,6 +19,11 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import { + parseWslRuntimeArchiveHash, + WSL_RUNTIME_ARCHIVE_FILENAME, + WSL_RUNTIME_ARCHIVE_HASH_FILENAME, +} from "@t3tools/shared/wslRuntimeArchive"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -238,6 +243,11 @@ const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { readonly distro: string | null; + readonly isPackaged: boolean; + readonly packagedRuntime: { + readonly windowsArchivePath: string; + readonly archiveHash: string; + } | null; readonly windowsEntryPath: string; readonly windowsRepoRoot: string; readonly allowBuild: boolean; @@ -299,16 +309,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; } - const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath); - if (Option.isNone(linuxEntry)) { - return { - _tag: "Failed", - reason: `wslpath conversion failed for ${input.windowsEntryPath}`, - fatal: false, - } as const; + let linuxRepoRoot: string | null = null; + if (input.isPackaged && input.packagedRuntime !== null) { + const prepared = yield* wslEnv.preparePackagedRuntime( + runningDistro, + input.packagedRuntime.windowsArchivePath, + input.packagedRuntime.archiveHash, + ); + if (!prepared.ok) { + yield* Effect.logWarning( + `Could not prepare the Linux-native WSL runtime; using the packaged runtime from its Windows mount instead. ${prepared.reason}`, + ); + } else { + linuxRepoRoot = prepared.linuxRepoRoot; + } + } + + if (linuxRepoRoot === null) { + const convertedRepoRoot = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsRepoRoot); + if (Option.isNone(convertedRepoRoot)) { + return { + _tag: "Failed", + reason: `wslpath conversion failed for ${input.windowsRepoRoot}`, + fatal: false, + } as const; + } + linuxRepoRoot = convertedRepoRoot.value; } - const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, { + const linuxEntryPath = `${linuxRepoRoot.replace(/\/+$/, "")}/apps/server/dist/bin.mjs`; + const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, linuxRepoRoot, { allowBuild: input.allowBuild, nodeEngineRange: serverPackageJson.engines.node, }); @@ -324,7 +354,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f return { _tag: "Ready", runningDistro, - linuxEntryPath: linuxEntry.value, + linuxEntryPath, nodePath: nodePtyResult.nodePath, resolvedPath: nodePtyResult.resolvedPath, } as const; @@ -428,6 +458,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const fileSystem = yield* FileSystem.FileSystem; // Bind to 0.0.0.0 inside WSL so the backend is reachable both via // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 @@ -469,15 +500,40 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) - // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is - // already a real directory, so this is a no-op. + // to the app.asar.unpacked sibling. That directory is the packaged staging + // source; preflight copies it to WSL's native filesystem and launches there. + // In dev appRoot is already a real checkout, so preflight uses it in place. const wslAppRoot = environment.isPackaged ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") : environment.appRoot; const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); + const runtimeArchivePath = environment.path.join( + environment.resourcesPath, + WSL_RUNTIME_ARCHIVE_FILENAME, + ); + const runtimeArchiveHashPath = environment.path.join( + environment.resourcesPath, + WSL_RUNTIME_ARCHIVE_HASH_FILENAME, + ); + const runtimeArchiveHash = environment.isPackaged + ? yield* fileSystem.readFileString(runtimeArchiveHashPath).pipe( + Effect.map(parseWslRuntimeArchiveHash), + Effect.orElseSucceed(() => null), + ) + : null; + if (environment.isPackaged && runtimeArchiveHash === null) { + yield* Effect.logWarning( + "The packaged WSL runtime archive identity is missing or invalid; using the existing Windows-mounted runtime.", + ); + } const preflight = yield* runWslPreflight({ distro: input.distro, + isPackaged: environment.isPackaged, + packagedRuntime: + environment.isPackaged && runtimeArchiveHash !== null + ? { windowsArchivePath: runtimeArchivePath, archiveHash: runtimeArchiveHash } + : null, windowsEntryPath: wslEntryPath, windowsRepoRoot: wslAppRoot, // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e3689..3586986c1069 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,3 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off - these integration tests execute real POSIX shell tools against disposable directories. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { describe, it } from "@effect/vitest"; import { expect } from "vite-plus/test"; import * as Duration from "effect/Duration"; @@ -10,20 +17,89 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + buildPackagedRuntimeStageScript, buildWslNodeEnvPreamble, DesktopWslDistroListError, formatMissingToolsReason, formatNodePtyProbeFailureReason, + formatPackagedRuntimeStageFailure, formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, probeWslDistros, + WSL_SCRIPT_SHELL_ARGS, } from "./DesktopWslEnvironment.ts"; +import { parseWslRuntimeArchiveHash } from "@t3tools/shared/wslRuntimeArchive"; const encoder = new TextEncoder(); +const makeRuntimeArchiveFixture = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-runtime-")); + const source = NodePath.join(root, "source"); + const archive = NodePath.join(root, "wsl-runtime.tar.gz"); + const tools = NodePath.join(root, "tools"); + const cache = NodePath.join(root, "cache"); + NodeFS.mkdirSync(NodePath.join(source, "apps/server/dist"), { recursive: true }); + NodeFS.mkdirSync(NodePath.join(source, "node_modules/effect"), { recursive: true }); + NodeFS.mkdirSync(tools); + NodeFS.writeFileSync(NodePath.join(source, "apps/server/dist/bin.mjs"), "version one\n"); + NodeFS.writeFileSync(NodePath.join(source, "node_modules/effect/package.json"), "{}\n"); + NodeFS.writeFileSync(NodePath.join(tools, "wslpath"), '#!/bin/sh\nprintf "%s\\n" "$2"\n', { + mode: 0o755, + }); + + const pack = (archivePath = archive) => { + const result = NodeChildProcess.spawnSync( + "tar", + ["-czf", archivePath, "-C", source, "apps", "node_modules"], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + return NodeCrypto.createHash("sha256").update(NodeFS.readFileSync(archivePath)).digest("hex"); + }; + const env = { + ...process.env, + HOME: root, + PATH: `${tools}:${process.env.PATH ?? ""}`, + XDG_CACHE_HOME: cache, + }; + const run = (archiveHash: string, archivePath = archive) => + NodeChildProcess.spawnSync( + "bash", + ["-c", buildPackagedRuntimeStageScript(archivePath, archiveHash)], + { encoding: "utf8", env }, + ); + const runAsync = (archiveHash: string, archivePath = archive) => + new Promise<{ + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; + }>((resolve) => { + const child = NodeChildProcess.spawn( + "bash", + ["-c", buildPackagedRuntimeStageScript(archivePath, archiveHash)], + { env }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); + const runtimeBase = NodePath.join(cache, "t3code/desktop-wsl-runtime"); + const runtimePath = (archiveHash: string) => NodePath.join(runtimeBase, `sha256-${archiveHash}`); + + return { archive, pack, root, run, runAsync, runtimePath, source, tools }; +}; + const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => Effect.succeed( @@ -110,6 +186,48 @@ describe("formatWslShellTransportFailureReason", () => { }); }); +describe("WSL scripted shell transport", () => { + const runScript = (home: string, script: string) => { + const [, , ...bashArgs] = WSL_SCRIPT_SHELL_ARGS; + return NodeChildProcess.spawnSync("bash", bashArgs, { + input: script, + encoding: "utf8", + env: { ...process.env, HOME: home }, + }); + }; + + it("preserves exported login state without letting logout hooks rewrite success", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-shell-")); + try { + NodeFS.writeFileSync( + NodePath.join(home, ".bash_profile"), + "export T3_PROFILE_VALUE=loaded\n", + ); + NodeFS.writeFileSync(NodePath.join(home, ".bash_logout"), "false\n"); + + const result = runScript(home, 'set -eu\nprintf "%s\\n" "$T3_PROFILE_VALUE"\nexit 0\n'); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("loaded\n"); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }); + + it("preserves a scripted failure status", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-wsl-shell-")); + try { + NodeFS.writeFileSync(NodePath.join(home, ".bash_logout"), "false\n"); + + const result = runScript(home, "set -eu\nexit 17\n"); + + expect(result.status).toBe(17); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("buildWslNodeEnvPreamble", () => { it("passes the required Node engine range into the shared resolver", () => { const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); @@ -125,6 +243,244 @@ describe("buildWslNodeEnvPreamble", () => { }); }); +describe("buildPackagedRuntimeStageScript", () => { + it("checks the Linux-native cache before converting or reading the mounted source", () => { + const script = buildPackagedRuntimeStageScript( + "C:\\Program Files\\T3 Code\\resources\\app.asar.unpacked", + "1.2.3-x64", + ); + const cacheHit = script.indexOf('if [ "$(cat "$manifest_path"'); + const sourceConversion = script.indexOf("wslpath -u"); + + expect(cacheHit).toBeGreaterThanOrEqual(0); + expect(sourceConversion).toBeGreaterThan(cacheHit); + expect(script.slice(cacheHit, sourceConversion)).toContain( + 'runtimeRoot:%s\\n\' "$runtime_dir"', + ); + expect(script.slice(cacheHit, sourceConversion)).toContain("exit 0"); + }); + + it("derives an immutable runtime root from the validated archive hash", () => { + const archiveHash = "b".repeat(64); + const script = buildPackagedRuntimeStageScript( + "C:\\Program Files\\T3 Code\\resources\\wsl-runtime.tar.gz", + archiveHash, + ); + + expect(script).toContain('runtime_dir="$runtime_base/sha256-$archive_hash"'); + expect(script).not.toContain('current_dir="$runtime_base/current"'); + }); + + it("falls back from an XDG cache on a Windows-mounted filesystem", () => { + const script = buildPackagedRuntimeStageScript( + "C:\\Program Files\\T3 Code\\resources\\app.asar.unpacked", + "1.2.3-x64", + ); + const xdgSelection = script.indexOf('cache_home="$XDG_CACHE_HOME"'); + const filesystemProbe = script.indexOf('findmnt -T "$cache_home"'); + const windowsMountFallback = script.indexOf("9p|drvfs|plan9|virtio-plan9|virtiofs"); + const runtimeBase = script.indexOf('runtime_base="$cache_home/t3code/desktop-wsl-runtime"'); + + expect(filesystemProbe).toBeGreaterThan(xdgSelection); + expect(windowsMountFallback).toBeGreaterThan(filesystemProbe); + expect(runtimeBase).toBeGreaterThan(windowsMountFallback); + expect(script.slice(filesystemProbe, runtimeBase)).toContain( + 'cache_home="${HOME:?WSL home directory is unavailable}/.cache"', + ); + }); + + it("serializes cache misses and rechecks the cache before reading the mounted source", () => { + const script = buildPackagedRuntimeStageScript( + "C:\\Program Files\\T3 Code\\resources\\app.asar.unpacked", + "1.2.3-x64", + ); + const firstCacheCheck = script.indexOf('if [ "$(cat "$manifest_path"'); + const lock = script.indexOf("flock -x 9"); + const secondCacheCheck = script.indexOf('if [ "$(cat "$manifest_path"', firstCacheCheck + 1); + const sourceConversion = script.indexOf("wslpath -u"); + + expect(lock).toBeGreaterThan(firstCacheCheck); + expect(secondCacheCheck).toBeGreaterThan(lock); + expect(sourceConversion).toBeGreaterThan(secondCacheCheck); + expect(script).toContain('if ! source_archive=$(wslpath -u "$windows_archive_path"); then'); + expect(script.slice(sourceConversion)).toContain("exit 5"); + }); + + it("extracts one packaged archive instead of copying the mounted file tree", () => { + const script = buildPackagedRuntimeStageScript( + "C:\\Program Files\\T3 Code\\resources\\wsl-runtime.tar.gz", + "b".repeat(64), + ); + + expect(script).toContain('sha256sum "$source_archive"'); + expect(script).toContain('if [ "$actual_hash" != "$archive_hash" ]; then'); + expect(script).toContain('tar -xzf "$source_archive" -C "$staging_dir"'); + expect(script).not.toContain('cp -a "$source_root/." "$staging_dir/"'); + }); + + it("extracts once, then starts from the native cache without reading the archive", () => { + const fixture = makeRuntimeArchiveFixture(); + try { + const archiveHash = fixture.pack(); + const runtimePath = fixture.runtimePath(archiveHash); + + const cold = fixture.run(archiveHash); + expect(cold.status, cold.stderr).toBe(0); + expect( + NodeFS.readFileSync(NodePath.join(runtimePath, ".t3code-runtime-sha256"), "utf8"), + ).toBe(`${archiveHash}\n`); + expect( + NodeFS.readFileSync(NodePath.join(runtimePath, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version one\n"); + + NodeFS.rmSync(fixture.archive); + const warm = fixture.run(archiveHash); + expect(warm.status, warm.stderr).toBe(0); + expect(warm.stdout).toContain(`runtimeRoot:${runtimePath}`); + } finally { + NodeFS.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("keeps a returned runtime pinned when a later archive is staged", () => { + const fixture = makeRuntimeArchiveFixture(); + try { + const firstHash = fixture.pack(); + const first = fixture.run(firstHash); + expect(first.status, first.stderr).toBe(0); + const firstRuntime = fixture.runtimePath(firstHash); + + NodeFS.writeFileSync( + NodePath.join(fixture.source, "apps/server/dist/bin.mjs"), + "version two\n", + ); + const secondHash = fixture.pack(); + const updated = fixture.run(secondHash); + + expect(updated.status, updated.stderr).toBe(0); + expect( + NodeFS.readFileSync(NodePath.join(firstRuntime, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version one\n"); + const secondRuntime = fixture.runtimePath(secondHash); + expect(secondRuntime).not.toBe(firstRuntime); + expect( + NodeFS.readFileSync(NodePath.join(secondRuntime, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version two\n"); + expect( + NodeFS.readFileSync(NodePath.join(secondRuntime, ".t3code-runtime-sha256"), "utf8"), + ).toBe(`${secondHash}\n`); + } finally { + NodeFS.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("keeps the last good cache when a replacement archive is broken", () => { + const fixture = makeRuntimeArchiveFixture(); + try { + const goodHash = fixture.pack(); + expect(fixture.run(goodHash).status).toBe(0); + const goodRuntime = fixture.runtimePath(goodHash); + + NodeFS.writeFileSync(fixture.archive, "not a tar archive\n"); + const brokenHash = NodeCrypto.createHash("sha256") + .update(NodeFS.readFileSync(fixture.archive)) + .digest("hex"); + const broken = fixture.run(brokenHash); + + expect(broken.status).not.toBe(0); + expect( + NodeFS.readFileSync(NodePath.join(goodRuntime, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version one\n"); + expect( + NodeFS.readFileSync(NodePath.join(goodRuntime, ".t3code-runtime-sha256"), "utf8"), + ).toBe(`${goodHash}\n`); + } finally { + NodeFS.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("serializes concurrent cache misses so the archive is extracted once", async () => { + const fixture = makeRuntimeArchiveFixture(); + try { + const archiveHash = fixture.pack(); + const extractionLog = NodePath.join(fixture.root, "tar-calls.log"); + const realTar = NodeChildProcess.spawnSync("which", ["tar"], { + encoding: "utf8", + }).stdout.trim(); + NodeFS.writeFileSync( + NodePath.join(fixture.tools, "tar"), + `#!/bin/sh\nprintf 'extract\\n' >> ${JSON.stringify(extractionLog)}\nexec ${JSON.stringify(realTar)} "$@"\n`, + { mode: 0o755 }, + ); + + const results = await Promise.all([ + fixture.runAsync(archiveHash), + fixture.runAsync(archiveHash), + ]); + + for (const result of results) expect(result.status, result.stderr).toBe(0); + expect(NodeFS.readFileSync(extractionLog, "utf8")).toBe("extract\n"); + } finally { + NodeFS.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("keeps concurrent callers on their own archive contents", async () => { + const fixture = makeRuntimeArchiveFixture(); + try { + const firstArchive = NodePath.join(fixture.root, "runtime-one.tar.gz"); + const firstHash = fixture.pack(firstArchive); + NodeFS.writeFileSync( + NodePath.join(fixture.source, "apps/server/dist/bin.mjs"), + "version two\n", + ); + const secondArchive = NodePath.join(fixture.root, "runtime-two.tar.gz"); + const secondHash = fixture.pack(secondArchive); + + const [first, second] = await Promise.all([ + fixture.runAsync(firstHash, firstArchive), + fixture.runAsync(secondHash, secondArchive), + ]); + + expect(first.status, first.stderr).toBe(0); + expect(second.status, second.stderr).toBe(0); + const firstRuntime = fixture.runtimePath(firstHash); + const secondRuntime = fixture.runtimePath(secondHash); + expect(first.stdout).toContain(`runtimeRoot:${firstRuntime}`); + expect(second.stdout).toContain(`runtimeRoot:${secondRuntime}`); + expect( + NodeFS.readFileSync(NodePath.join(firstRuntime, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version one\n"); + expect( + NodeFS.readFileSync(NodePath.join(secondRuntime, "apps/server/dist/bin.mjs"), "utf8"), + ).toBe("version two\n"); + } finally { + NodeFS.rmSync(fixture.root, { recursive: true, force: true }); + } + }); +}); + +describe("formatPackagedRuntimeStageFailure", () => { + it("keeps the staging failure reason for fallback logging", () => { + expect(formatPackagedRuntimeStageFailure(5, "wslpath conversion failed")).toEqual({ + ok: false, + reason: "Failed to prepare the packaged WSL runtime (exit 5): wslpath conversion failed", + }); + }); +}); + +describe("parseWslRuntimeArchiveHash", () => { + it("normalizes a valid SHA-256 sidecar", () => { + expect(parseWslRuntimeArchiveHash(`${"A".repeat(64)}\n`)).toBe("a".repeat(64)); + }); + + it("rejects missing and malformed identities", () => { + expect(parseWslRuntimeArchiveHash("")).toBeNull(); + expect(parseWslRuntimeArchiveHash("not-a-hash")).toBeNull(); + expect(parseWslRuntimeArchiveHash("a".repeat(63))).toBeNull(); + }); +}); + describe("parseToolchainReport", () => { it("returns no missing tools and no node version on empty output", () => { expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500b..76d464811d0b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -22,6 +22,7 @@ const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); +const PACKAGED_RUNTIME_STAGE_TIMEOUT = Duration.minutes(10); const USER_HOME_TIMEOUT = Duration.seconds(5); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; @@ -44,6 +45,16 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; +export type PrepareWslPackagedRuntimeResult = + | { + readonly ok: true; + readonly linuxRepoRoot: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export class DesktopWslDistroListError extends Schema.TaggedErrorClass()( "DesktopWslDistroListError", { reason: Schema.String }, @@ -69,6 +80,13 @@ export class DesktopWslEnvironment extends Context.Service< distro: string | null, windowsPath: string, ) => Effect.Effect>; + // Materializes the packaged server runtime onto the distro's native + // filesystem. A matching cached version never touches the Windows mount. + readonly preparePackagedRuntime: ( + distro: string | null, + windowsArchivePath: string, + archiveHash: string, + ) => Effect.Effect; // Resolves the user's Linux home dir inside the chosen distro (e.g. // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect>; @@ -81,7 +99,7 @@ export class DesktopWslEnvironment extends Context.Service< readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly ensureNodePty: ( distro: string | null, - windowsRepoRoot: string, + linuxRepoRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect; } @@ -90,6 +108,10 @@ export class DesktopWslEnvironment extends Context.Service< const buildDistroArgs = (distro: string | null): ReadonlyArray => distro ? ["-d", distro] : []; +// Load the user's login profile, then replace that shell with a non-login child +// so ~/.bash_logout cannot rewrite the stdin script's exit status. +export const WSL_SCRIPT_SHELL_ARGS = ["--", "bash", "-l", "-c", "exec bash -s"] as const; + const concatChunks = (arrays: ReadonlyArray): Uint8Array => { let totalLength = 0; for (const arr of arrays) totalLength += arr.byteLength; @@ -145,23 +167,19 @@ ensure_remote_node_path || true // wsl.exe re-escapes args before forwarding them to the Linux side, which // mangles quotes inside `bash -lc "