diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f..cd0c899d50a 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,6 +17,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopInstallIntegrity from "./DesktopInstallIntegrity.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -283,6 +284,17 @@ const startup = Effect.gen(function* () { backend: Option.getOrElse(selectedBackend, () => "unknown"), }); } + // Refuse to boot from a half-applied update (app.asar and + // app.asar.unpacked stamped by different builds) before anything loads + // from the mismatched halves — the alternative is an opaque + // ERR_MODULE_NOT_FOUND crash in the main process. + const downloadPageUrl = yield* DesktopInstallIntegrity.resolveDownloadPageUrl; + const installIntact = yield* DesktopInstallIntegrity.enforceInstallIntegrity({ + downloadPageUrl, + }); + if (!installIntact) { + return; + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; diff --git a/apps/desktop/src/app/DesktopInstallIntegrity.test.ts b/apps/desktop/src/app/DesktopInstallIntegrity.test.ts new file mode 100644 index 00000000000..d12fab340b4 --- /dev/null +++ b/apps/desktop/src/app/DesktopInstallIntegrity.test.ts @@ -0,0 +1,231 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Path from "effect/Path"; + +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopInstallIntegrity from "./DesktopInstallIntegrity.ts"; + +const makeEnvironmentLayer = (input: { + readonly resourcesPath: string; + readonly isPackaged: boolean; + readonly homeDirectory: string; + readonly platform?: NodeJS.Platform; +}) => + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: input.homeDirectory, + platform: input.platform ?? "win32", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: input.isPackaged, + resourcesPath: input.resourcesPath, + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: input.homeDirectory, + }), + ), + ), + ); + +const withInstallDir = ( + build: (input: { + readonly resourcesPath: string; + readonly manifestPath: string; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-install-integrity-test-", + }); + const resourcesPath = path.join(baseDir, "resources"); + const manifestPath = path.join( + resourcesPath, + ...DesktopInstallIntegrity.DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"), + ); + yield* fileSystem.makeDirectory(path.dirname(manifestPath), { recursive: true }); + return yield* build({ resourcesPath, manifestPath }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +describe("DesktopInstallIntegrity", () => { + it.effect("accepts an install whose unpacked build stamp matches the asar version", () => + withInstallDir(({ resourcesPath, manifestPath }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.3"}\n'); + + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + ), + ); + assert.equal(result._tag, "Ok"); + }), + ), + ); + + it.effect("flags a half-applied update whose unpacked files came from a different build", () => + withInstallDir(({ resourcesPath, manifestPath }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + // The observed corruption: app.asar (and its version) stayed at the + // old build while app.asar.unpacked was replaced by the new one. + yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.4-nightly.1"}\n'); + + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + ), + ); + assert.equal(result._tag, "Mismatch"); + if (result._tag === "Mismatch") { + assert.equal(result.appVersion, "1.2.3"); + assert.equal(result.unpackedVersion, "1.2.4-nightly.1"); + } + }), + ), + ); + + it.effect("treats a missing manifest on packaged Windows as a half-applied update", () => + withInstallDir(({ resourcesPath }) => + Effect.gen(function* () { + // New app.asar (which ships this checker AND the stamp in the same + // build) with a pre-stamp unpacked tree = partial apply. Skipping + // here would launch straight into the mixed-build crash. + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + ), + ); + assert.equal(result._tag, "MissingManifest"); + }), + ), + ); + + it.effect("skips a missing manifest on platforms that pack the server dist inside the asar", () => + withInstallDir(({ resourcesPath }) => + Effect.gen(function* () { + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + platform: "darwin", + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + ), + ); + assert.equal(result._tag, "Skipped"); + }), + ), + ); + + it.effect("skips an unreadable (but present) manifest instead of blocking launch", () => + withInstallDir(({ resourcesPath }) => + Effect.gen(function* () { + // Access denied / I/O error is not evidence of a partial apply; a + // false MissingManifest here would brick every launch of a healthy + // install. + const failingFs = FileSystem.layerNoop({ + readFileString: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: "desktop-build-manifest.json", + description: "EACCES", + }), + ), + }); + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + Layer.mergeAll( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + failingFs, + ), + ), + ); + assert.equal(result._tag, "Skipped"); + }), + ), + ); + + it.effect("skips undecodable manifests instead of blocking launch", () => + withInstallDir(({ resourcesPath, manifestPath }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(manifestPath, "not json"); + + const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( + Effect.provide( + makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }), + ), + ); + assert.equal(result._tag, "Skipped"); + }), + ), + ); + + it.effect("resolves the releases page from the packaged update feed", () => + withInstallDir(({ resourcesPath }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environmentLayer = makeEnvironmentLayer({ + resourcesPath, + isPackaged: true, + homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, + }); + const appUpdateYmlPath = yield* DesktopEnvironment.DesktopEnvironment.pipe( + Effect.map((environment) => environment.appUpdateYmlPath), + Effect.provide(environmentLayer), + ); + yield* fileSystem.makeDirectory( + appUpdateYmlPath.slice(0, appUpdateYmlPath.lastIndexOf("/")), + { recursive: true }, + ); + yield* fileSystem.writeFileString( + appUpdateYmlPath, + "provider: github\nowner: pingdotgg\nrepo: t3code\n", + ); + + const url = yield* DesktopInstallIntegrity.resolveDownloadPageUrl.pipe( + Effect.provide(environmentLayer), + ); + assert.deepEqual(url, Option.some("https://github.com/pingdotgg/t3code/releases")); + }), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopInstallIntegrity.ts b/apps/desktop/src/app/DesktopInstallIntegrity.ts new file mode 100644 index 00000000000..ccf85c91894 --- /dev/null +++ b/apps/desktop/src/app/DesktopInstallIntegrity.ts @@ -0,0 +1,227 @@ +// Post-apply install integrity check. A Windows update apply is not atomic: +// NSIS replaces files one by one, and a file held open (e.g. via a WSL 9p +// handle) is silently skipped in silent-install mode, leaving app.asar and +// app.asar.unpacked from DIFFERENT builds. That state crashes later with an +// opaque ERR_MODULE_NOT_FOUND in the main process. The desktop build stamps +// apps/server/dist/desktop-build-manifest.json (asar-unpacked on Windows) +// with the build version; comparing it against the asar's own version at +// startup detects a half-applied update before anything loads from the +// mismatched halves, and turns the opaque crash into an actionable dialog. + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; + +// Kept in sync with scripts/build-desktop-artifact.ts, which writes the +// manifest into the staged apps/server/dist so it lands under the Windows +// asarUnpack globs. +export const DESKTOP_BUILD_MANIFEST_RELATIVE_PATH = + "app.asar.unpacked/apps/server/dist/desktop-build-manifest.json"; + +export const DesktopBuildManifest = Schema.Struct({ + version: Schema.String, +}); +export type DesktopBuildManifest = typeof DesktopBuildManifest.Type; + +const decodeDesktopBuildManifest = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopBuildManifest), +); + +export type DesktopInstallIntegrityResult = + | { readonly _tag: "Ok" } + // No manifest to compare against where none is expected: dev runs, + // macOS/Linux (which pack the server dist inside the asar), or an + // unreadable/corrupt manifest. Never blocks launch. + | { readonly _tag: "Skipped"; readonly reason: string } + // Packaged Windows build with no manifest at all. Every Windows artifact + // that ships this checker also ships the stamp (both come from the same + // build script), so a missing manifest means the unpacked tree is from an + // older, pre-stamp build — i.e. a half-applied update that replaced + // app.asar but not app.asar.unpacked. + | { readonly _tag: "MissingManifest"; readonly appVersion: string; readonly manifestPath: string } + | { + readonly _tag: "Mismatch"; + readonly appVersion: string; + readonly unpackedVersion: string; + readonly manifestPath: string; + }; + +const { logWarning: logIntegrityWarning, logError: logIntegrityError } = + DesktopObservability.makeComponentLogger("desktop-install-integrity"); + +export const checkInstallIntegrity: Effect.Effect< + DesktopInstallIntegrityResult, + never, + DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem +> = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + if (!environment.isPackaged) { + return { _tag: "Skipped", reason: "not a packaged build" } as const; + } + + const manifestPath = environment.path.join( + environment.resourcesPath, + ...DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"), + ); + const read = yield* fileSystem.readFileString(manifestPath, "utf-8").pipe( + Effect.match({ + onFailure: (error) => + error.reason._tag === "NotFound" + ? ({ _tag: "NotFound" } as const) + : ({ _tag: "Unreadable", detail: error.message } as const), + onSuccess: (contents) => ({ _tag: "Found", contents }) as const, + }), + ); + if (read._tag === "Unreadable") { + // The manifest exists (or at least the failure is not "absent") but + // could not be read — access denied, I/O error. That is not evidence of + // a half-applied update, and blocking every launch on a transient read + // failure would brick healthy installs. Log loudly and let the launch + // proceed. + yield* logIntegrityWarning("build manifest could not be read; skipping integrity check", { + manifestPath, + detail: read.detail, + }); + return { _tag: "Skipped", reason: `unreadable build manifest at ${manifestPath}` } as const; + } + if (read._tag === "NotFound") { + if (environment.platform === "win32") { + return { + _tag: "MissingManifest", + appVersion: environment.appVersion, + manifestPath, + } as const; + } + return { _tag: "Skipped", reason: `no build manifest at ${manifestPath}` } as const; + } + + const manifest = yield* decodeDesktopBuildManifest(read.contents).pipe(Effect.option); + if (Option.isNone(manifest)) { + // A corrupt manifest could itself be fallout from a partial apply, but a + // false positive here bricks every launch — log loudly and let the + // version comparison stay the only hard gate. + yield* logIntegrityWarning("build manifest exists but could not be decoded", { + manifestPath, + }); + return { _tag: "Skipped", reason: `undecodable build manifest at ${manifestPath}` } as const; + } + + if (manifest.value.version === environment.appVersion) { + return { _tag: "Ok" } as const; + } + + return { + _tag: "Mismatch", + appVersion: environment.appVersion, + unpackedVersion: manifest.value.version, + manifestPath, + } as const; +}).pipe(Effect.withSpan("desktop.installIntegrity.check")); + +// Best-effort "where to get a fresh installer" link for the repair dialog, +// derived from the packaged update feed (app-update.yml). None when the +// build has no feed configured. +export const resolveDownloadPageUrl: Effect.Effect< + Option.Option, + never, + DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem +> = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const raw = yield* fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( + Effect.option, + ); + if (Option.isNone(raw)) { + return Option.none(); + } + const owner = raw.value.match(/^owner:\s*(.+)$/m)?.[1]?.trim(); + const repo = raw.value.match(/^repo:\s*(.+)$/m)?.[1]?.trim(); + if (!owner || !repo) { + return Option.none(); + } + return Option.some(`https://github.com/${owner}/${repo}/releases`); +}); + +// Runs the check and, on mismatch, refuses to continue launching: shows an +// actionable dialog (with a jump to the releases page when the update feed +// names one) and requests shutdown. Returns false when startup must stop. +export const enforceInstallIntegrity = (options: { + readonly downloadPageUrl: Option.Option; +}): Effect.Effect< + boolean, + never, + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem + | DesktopShutdown.DesktopShutdown + | DesktopState.DesktopState + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog + | ElectronShell.ElectronShell +> => + Effect.gen(function* () { + const result = yield* checkInstallIntegrity; + if (result._tag === "Ok") { + return true; + } + if (result._tag === "Skipped") { + return true; + } + + const shutdown = yield* DesktopShutdown.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const electronShell = yield* ElectronShell.ElectronShell; + + yield* logIntegrityError( + "install is internally inconsistent (half-applied update); refusing to launch", + { + appVersion: result.appVersion, + unpackedVersion: result._tag === "Mismatch" ? result.unpackedVersion : "", + manifestPath: result.manifestPath, + }, + ); + + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (!wasQuitting) { + const supportFilesDescription = + result._tag === "Mismatch" + ? `support files are version ${result.unpackedVersion}` + : `support files are from an older build with no version stamp`; + const message = + `This T3 Code installation is damaged: a previous update was only partially applied ` + + `(application core is version ${result.appVersion}, ${supportFilesDescription}). ` + + `Please reinstall T3 Code to repair it.`; + const buttons = Option.isSome(options.downloadPageUrl) + ? ["Open Download Page", "Quit"] + : ["Quit"]; + const response = yield* electronDialog + .showMessageBox({ + type: "error", + title: "T3 Code installation is damaged", + message, + buttons, + defaultId: 0, + cancelId: buttons.length - 1, + }) + .pipe(Effect.orElseSucceed(() => ({ response: buttons.length - 1 }))); + if (Option.isSome(options.downloadPageUrl) && response.response === 0) { + yield* electronShell.openExternal(options.downloadPageUrl.value).pipe(Effect.ignore); + } + } + yield* shutdown.request; + yield* electronApp.quit; + return false; + }).pipe(Effect.withSpan("desktop.installIntegrity.enforce")); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a..e757066952e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -201,9 +201,9 @@ describe("DesktopBackendConfiguration", () => { observedDistros.push(distro); return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; }, - getDistroIp: (distro) => { + getDistroIps: (distro: string | null) => { observedDistros.push(distro); - return Option.some("172.27.0.99"); + return ["172.27.0.99"]; }, }), ), @@ -256,7 +256,7 @@ describe("DesktopBackendConfiguration", () => { distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], windowsToWslPath: () => Option.some(linuxEntryPath), ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), - getDistroIp: () => Option.some("172.27.0.99"), + getDistroIps: () => ["172.27.0.99"], }), ), Layer.provideMerge( @@ -293,6 +293,111 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect( + "resolveWsl advertises loopback in mirrored mode and races every candidate instead of trusting hostname -I ordering", + () => + 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, ""); + + const resolveWith = (stub: { + readonly mode: DesktopWslEnvironment.WslNetworkingMode; + readonly ips: ReadonlyArray; + readonly runtimeStatePort?: number; + }) => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolveWsl({ port: 5000, 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 }], + windowsToWslPath: () => Option.some("/tmp/entry.mjs"), + ensureNodePty: () => ({ + ok: true, + nodePath: "/usr/bin/node", + resolvedPath: "/usr/bin:/bin", + }), + getDistroIps: () => stub.ips, + getNetworkingMode: () => stub.mode, + readServerRuntimeState: () => + Option.some({ + port: stub.runtimeStatePort ?? 5000, + host: "0.0.0.0", + origin: "http://127.0.0.1:5000", + }), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + isPackaged: true, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + + // Tailscale-in-WSL regression: mirrored networking, but a CGNAT + // address is FIRST in hostname -I. Loopback must be advertised and + // every enumerated address still probed as a candidate. + const mirrored = yield* resolveWith({ + mode: "mirrored", + ips: ["100.108.4.21", "192.168.127.5"], + }); + assert.equal(mirrored.httpBaseUrl.href, "http://127.0.0.1:5000/"); + assert.deepEqual( + (mirrored.readinessProbeUrls ?? []).map((url) => url.href), + ["http://100.108.4.21:5000/", "http://192.168.127.5:5000/"], + ); + + // NAT mode with a CGNAT address first: the advertised host must be + // the first non-CGNAT address, never position one. + const nat = yield* resolveWith({ + mode: "nat", + ips: ["100.108.4.21", "172.27.0.99"], + }); + assert.equal(nat.httpBaseUrl.href, "http://172.27.0.99:5000/"); + assert.deepEqual( + (nat.readinessProbeUrls ?? []).map((url) => url.href), + ["http://127.0.0.1:5000/", "http://100.108.4.21:5000/"], + ); + + // No addresses at all: loopback is the only sane advertisement. + const bare = yield* resolveWith({ mode: "unknown", ips: [] }); + assert.equal(bare.httpBaseUrl.href, "http://127.0.0.1:5000/"); + + // The runtime-state fallback trusts the persisted origin only when + // it names the port this instance owns. + const fallbackUrls = yield* mirrored.resolveReadinessFallbackUrls ?? Effect.succeed([]); + assert.deepEqual( + fallbackUrls.map((url) => url.href), + ["http://127.0.0.1:5000/", "http://127.0.0.1:5000/"], + ); + const mismatched = yield* resolveWith({ + mode: "mirrored", + ips: [], + runtimeStatePort: 9999, + }); + const mismatchedUrls = yield* mismatched.resolveReadinessFallbackUrls ?? + Effect.succeed([]); + assert.lengthOf(mismatchedUrls, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolvePrimary and resolveWsl share one token under concurrent resolution", () => withHarness( Effect.gen(function* () { @@ -490,7 +595,7 @@ describe("DesktopBackendConfiguration", () => { DesktopWslEnvironment.layerTest({ isAvailable: true, windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), + getDistroIps: () => ["172.27.0.99"], }), ), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e5..0dcfdbc14ed 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -16,6 +16,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json" import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -330,6 +331,29 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); +const { logInfo: logBackendConfigurationInfo } = DesktopObservability.makeComponentLogger( + "desktop-backend-configuration", +); + +// CGNAT range 100.64.0.0/10 — Tailscale hands these out; a WSL-side +// tailscaled surfaces one in `hostname -I`, and Windows either cannot route +// it or routes it through the Tailscale interface instead of the WSL +// vEthernet. Never advertise one as the renderer host. +const isCgnatIpv4 = (ip: string): boolean => { + const octets = ip.split("."); + if (octets.length !== 4 || octets[0] !== "100") return false; + const second = Number(octets[1]); + return Number.isInteger(second) && second >= 64 && second <= 127; +}; + +const parseUrlOrNull = (raw: string): URL | null => { + try { + return new URL(raw); + } catch { + return null; + } +}; + // True when the given IPv4 belongs to a Windows-side network // interface. In WSL2 mirrored mode the distro's eth0 IP equals the // host's, which is the signature we use to detect that mode and @@ -496,18 +520,67 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; - // Resolve the selected distro's IPv4 address. In mirrored mode the distro - // reports a host interface, so use loopback instead; a failed probe also - // falls back to loopback and preserves the previous behavior. - const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); - const usesSharedNetworkStack = Option.match(distroIp, { - onNone: () => false, - onSome: (ip) => isLocalHostIpv4(ip), - }); - const rendererHost = usesSharedNetworkStack - ? "127.0.0.1" - : Option.getOrElse(distroIp, () => "127.0.0.1"); + // Networking-mode detection, in priority order: + // 1. `wslinfo --networking-mode` — the first-party, authoritative answer. + // 2. Heuristic fallback for WSL builds without wslinfo: mirrored mode + // mirrors host interfaces into the distro, so ANY distro IPv4 matching + // a Windows-side interface address indicates mirrored networking. The + // previous first-address-only check broke whenever tailscaled or + // docker0 inside the distro reordered `hostname -I` (a CGNAT + // 100.64/10 Tailscale address in first position made a mirrored host + // look NAT'd and pointed the renderer at an unroutable IP). + // In mirrored mode loopback is shared with Windows and demonstrably works, + // so it is the advertised URL. Whatever gets advertised, readiness races + // loopback AND every enumerated distro address (readinessProbeUrls below); + // the first candidate to answer becomes the advertised URL, so a wrong + // guess here degrades startup by nothing worse than a probe race. + const distroIps = yield* wslEnvironment.getDistroIps(distroForConfig); + const networkingMode = yield* wslEnvironment.getNetworkingMode(distroForConfig); + const usesSharedNetworkStack = + networkingMode === "mirrored" || + (networkingMode === "unknown" && distroIps.some((ip) => isLocalHostIpv4(ip))); + const preferredDistroIp = distroIps.find((ip) => !isCgnatIpv4(ip) && !isLocalHostIpv4(ip)); + const rendererHost = usesSharedNetworkStack ? "127.0.0.1" : (preferredDistroIp ?? "127.0.0.1"); const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); + const readinessProbeUrls = [ + new URL(`http://127.0.0.1:${input.port}`), + ...distroIps.map((ip) => new URL(`http://${ip}:${input.port}`)), + ].filter((url) => url.href !== httpBaseUrl.href); + // Last-resort readiness fallback: the backend persists its actual origin + // (server-runtime.json) once its HTTP listener is up. Read the state-dir + // flavor this spawn actually uses (--dev-url runs persist under dev/, + // packaged under userdata/), and only trust the file when it names the + // port this instance was told to bind — the file is per-distro-home, so + // another backend instance may own it. + const wslServerStateDir: DesktopWslEnvironment.WslServerStateDir = Option.isSome( + environment.devServerUrl, + ) + ? "dev" + : "userdata"; + const resolveReadinessFallbackUrls = wslEnvironment + .readServerRuntimeState(distroForConfig, wslServerStateDir) + .pipe( + Effect.map( + Option.match({ + onNone: () => [] as ReadonlyArray, + onSome: (runtimeState) => { + if (runtimeState.port !== input.port) return [] as ReadonlyArray; + const origin = parseUrlOrNull(runtimeState.origin); + return [ + ...(origin === null ? [] : [origin]), + new URL(`http://127.0.0.1:${runtimeState.port}`), + ] as ReadonlyArray; + }, + }), + ), + ); + yield* logBackendConfigurationInfo("resolved WSL backend endpoint candidates", { + distro: distroForConfig, + networkingMode, + distroIps: distroIps.join(" "), + advertisedUrl: httpBaseUrl.href, + probeUrls: readinessProbeUrls.map((url) => url.href).join(" "), + }); const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; const forwardedEnv: Record = {}; @@ -548,6 +621,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl bootstrap, bootstrapDelivery: "stdin" as const, httpBaseUrl, + readinessProbeUrls, + resolveReadinessFallbackUrls, captureOutput: true, ...(runningDistro !== null ? { runningDistro } : {}), }; diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index a32caa1fd37..bc0527367f9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -119,6 +119,7 @@ interface MakeInstanceInput { readonly httpClientLayer?: Layer.Layer; readonly backendOutputLog?: Partial; readonly onReady?: Effect.Effect; + readonly onReadyBaseUrl?: (readyBaseUrl: URL) => Effect.Effect; readonly onShutdown?: Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, @@ -173,7 +174,11 @@ function makeTestInstance(input: MakeInstanceInput) { id: DesktopBackendManager.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), - ...(input.onReady ? { onReady: () => input.onReady! } : {}), + ...(input.onReadyBaseUrl + ? { onReady: input.onReadyBaseUrl } + : input.onReady + ? { onReady: () => input.onReady! } + : {}), ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), }); @@ -1397,4 +1402,370 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("races readiness probe candidates and rebinds the advertised base URL to the winner", () => + Effect.scoped( + Effect.gen(function* () { + // Regression for the Tailscale-in-WSL hang: the advertised host is a + // CGNAT address that never answers, while loopback (a fallback + // candidate) works the whole time. Readiness must succeed via the + // candidate and the config must advertise the URL that answered. + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + httpBaseUrl: new URL("http://100.108.0.5:3773"), + readinessProbeUrls: [new URL("http://127.0.0.1:3773")], + }, + httpClientLayer: httpClientLayer((request) => + Effect.succeed( + responseForRequest(request, request.url.includes("127.0.0.1") ? 200 : 503), + ), + ), + onReadyBaseUrl: (readyBaseUrl) => + Deferred.succeed(ready, readyBaseUrl).pipe(Effect.asVoid), + }); + + yield* instance.start; + const readyBaseUrl = yield* Deferred.await(ready); + assert.equal(readyBaseUrl.href, "http://127.0.0.1:3773/"); + + const rebound = yield* instance.currentConfig; + assert.isTrue(Option.isSome(rebound)); + if (Option.isSome(rebound)) { + assert.equal(rebound.value.httpBaseUrl.href, "http://127.0.0.1:3773/"); + } + assert.isTrue((yield* instance.snapshot).ready); + + yield* Deferred.succeed(exit, void 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("recovers readiness from runtime-state fallback URLs after static candidates time out", () => + Effect.scoped( + Effect.gen(function* () { + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + let fallbackResolves = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + httpBaseUrl: new URL("http://100.108.0.5:3773"), + resolveReadinessFallbackUrls: Effect.sync(() => { + fallbackResolves += 1; + return [new URL("http://127.0.0.1:3773")]; + }), + }, + httpClientLayer: httpClientLayer((request) => + Effect.succeed( + responseForRequest(request, request.url.includes("127.0.0.1") ? 200 : 503), + ), + ), + onReadyBaseUrl: (readyBaseUrl) => + Deferred.succeed(ready, readyBaseUrl).pipe(Effect.asVoid), + }); + + yield* instance.start; + assert.equal(fallbackResolves, 0); + + // Static candidate (the CGNAT address) times out after a minute; + // only then is the persisted runtime state consulted. + yield* TestClock.adjust(Duration.minutes(1)); + + const readyBaseUrl = yield* Deferred.await(ready); + assert.equal(readyBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(fallbackResolves, 1); + assert.isTrue((yield* instance.snapshot).ready); + + yield* Deferred.succeed(exit, void 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("terminates unreachable runs, retries, and surfaces after the readiness failure cap", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); + const preflightFailures: Array = []; + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + const killed = yield* Deferred.make(); + // Emulate the real spawner: closing the run scope kills the + // child, which resolves its exit code. + yield* Scope.addFinalizer(scope, Deferred.succeed(killed, void 0).pipe(Effect.asVoid)); + yield* Queue.offer(starts, startCount); + return makeProcess({ + exitCode: Deferred.await(killed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + httpBaseUrl: new URL("http://100.108.0.5:3773"), + }, + httpClientLayer: httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 503)), + ), + onPreflightFailed: (failure) => + Effect.sync(() => { + preflightFailures.push(failure); + return false; + }), + backendOutputLog: { + // Readiness terminations are deliberate (stopRequested), so the + // finalize path discards the session (the failure snapshot was + // already persisted from onReadinessFailure). + discardSession: Queue.offer(failures, "finalized").pipe(Effect.asVoid), + }, + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + + // Attempt 1: readiness times out, the limbo run is terminated and a + // restart is scheduled instead of hanging forever. + yield* TestClock.adjust(Duration.minutes(1)); + yield* Queue.take(failures); + assert.lengthOf(preflightFailures, 0); + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(yield* Queue.take(starts), 2); + + // Attempt 2. + yield* TestClock.adjust(Duration.minutes(1)); + yield* Queue.take(failures); + assert.lengthOf(preflightFailures, 0); + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(yield* Queue.take(starts), 3); + + // Attempt 3 hits the cap: the failure is surfaced with the probed + // URL, and (onPreflightFailed returned false) the instance stops. + yield* TestClock.adjust(Duration.minutes(1)); + yield* Queue.take(failures); + assert.lengthOf(preflightFailures, 1); + assert.include(preflightFailures[0]!.reason, "100.108.0.5"); + assert.isFalse(preflightFailures[0]!.fatal); + + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(starts), 0); + const snapshot = yield* instance.snapshot; + assert.isFalse(snapshot.desiredRunning); + assert.isFalse(snapshot.restartScheduled); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("readiness terminations do not count toward the never-ready exit cap", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const finalized = yield* Queue.unbounded(); + const preflightFailures: Array = []; + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + const killed = yield* Deferred.make(); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(killed, void 0).pipe(Effect.asVoid), + ); + yield* Queue.offer(starts, startCount); + return makeProcess({ + exitCode: Deferred.await(killed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + httpBaseUrl: new URL("http://100.108.0.5:3773"), + }, + httpClientLayer: httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 503)), + ), + // Always retry, like the primary's Windows-fallback path — so the + // instance keeps cycling readiness timeouts past the exit cap's + // threshold. + onPreflightFailed: (failure) => + Effect.sync(() => { + preflightFailures.push(failure); + return true; + }), + backendOutputLog: { + discardSession: Queue.offer(finalized, void 0).pipe(Effect.asVoid), + }, + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + + // Five readiness-timeout cycles: each kill is a deliberate + // termination and must NOT feed the never-ready exit streak — with + // double counting, cycle 5 would fire a second, misleading + // "exited N times" surfacing. + for (let cycle = 1; cycle <= 5; cycle += 1) { + yield* TestClock.adjust(Duration.minutes(1)); + yield* Queue.take(finalized); + if (cycle < 5) { + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(yield* Queue.take(starts), cycle + 1); + } + } + + // Only the readiness cap (cycle 3) surfaced; every reason names the + // probed URL, and none claims the backend "exited". + assert.lengthOf(preflightFailures, 1); + assert.include(preflightFailures[0]!.reason, "readiness"); + for (const failure of preflightFailures) { + assert.notInclude(failure.reason, "exited"); + } + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("caps runs that keep exiting before ready and surfaces via onPreflightFailed", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); + const preflightFailures: Array = []; + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + onPreflightFailed: (failure) => + Effect.sync(() => { + preflightFailures.push(failure); + return false; + }), + backendOutputLog: { + persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), + }, + }); + + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); + yield* Queue.take(failures); + + // Four more spawn-then-exit-before-ready cycles exhaust the cap. + for (let attempt = 2; attempt <= 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(15)); + assert.equal(yield* Queue.take(starts), attempt); + yield* Queue.take(failures); + } + + // The fifth never-ready exit surfaces instead of scheduling another + // silent restart; onPreflightFailed returned false, so the instance + // stops. + yield* TestClock.adjust(Duration.seconds(30)); + assert.lengthOf(preflightFailures, 1); + assert.include(preflightFailures[0]!.reason, "5 times"); + assert.isFalse(preflightFailures[0]!.fatal); + assert.equal(yield* Queue.size(starts), 0); + const snapshot = yield* instance.snapshot; + assert.isFalse(snapshot.desiredRunning); + assert.isFalse(snapshot.restartScheduled); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("stop reports whether the child fully finalized within the timeout", () => + Effect.scoped( + Effect.gen(function* () { + const neverExits = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // The child ignores the kill: exitCode never resolves, so a + // bounded stop cannot verify termination and must say so. + exitCode: Deferred.await(neverExits).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; + const stopFiber = yield* instance + .stop({ timeout: Duration.seconds(5) }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust(Duration.seconds(5)); + assert.isFalse(yield* Fiber.join(stopFiber)); + + // Once the child actually exits, a subsequent stop verifies cleanly. + yield* Deferred.succeed(neverExits, void 0); + assert.isTrue(yield* instance.stop({ timeout: Duration.seconds(5) })); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b50c7a55ed7..70fa2cd03b9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -60,7 +60,21 @@ const MAX_RESTART_DELAY = Duration.seconds(10); // failures may instead provide their own larger retryLimit when they should // self-heal for a while but must not leave the app connecting forever. const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; +// After this many consecutive readiness timeouts (server process alive but no +// candidate URL answering), stop silently cycling and surface the reason via +// onPreflightFailed — a live-but-unreachable backend must not present as an +// indefinite "connecting" state. +const MAX_READINESS_FAILURE_ATTEMPTS = 3; +// After this many consecutive runs that spawned but exited without ever +// becoming ready, surface via onPreflightFailed instead of restarting +// forever. Scoped strictly to post-spawn exits: pre-spawn preflight retries +// (WSL cold-start) have their own counter, and a crash AFTER the backend was +// ready resets the streak — that's a different failure than "never comes up". +const MAX_EXIT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); +// Budget for probing URLs recovered from the backend's persisted runtime +// state (server-runtime.json) after every static candidate timed out. +const READINESS_FALLBACK_PROBE_TIMEOUT = Duration.seconds(15); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); @@ -99,6 +113,16 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; + // Additional base URLs probed for readiness alongside httpBaseUrl. The + // first candidate to answer wins and becomes the advertised base URL. + // The WSL path uses this to probe 127.0.0.1 (wslhost forwarding) next to + // the distro-IP heuristic, since either transport can be broken on a + // given Windows host while the other works. + readonly readinessProbeUrls?: ReadonlyArray; + // Consulted once after every static candidate timed out. Lets the WSL + // path recover the true origin from the backend's persisted runtime + // state (server-runtime.json) before the run is declared unreachable. + readonly resolveReadinessFallbackUrls?: Effect.Effect>; } // A preflight failure records whether it is fatal. Transient failures (WSL @@ -130,11 +154,13 @@ export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass url.href) ?? [this.readinessUrl.href]; + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${probed.join(", ")}.`; } } @@ -224,7 +250,10 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly outputDrainTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onExitObserved?: () => Effect.Effect; - readonly onReady?: () => Effect.Effect; + // Receives the candidate base URL that actually answered the readiness + // probe — not necessarily config.httpBaseUrl when readinessProbeUrls or + // the runtime-state fallback produced the winner. + readonly onReady?: (readyBaseUrl: URL) => Effect.Effect; readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, @@ -260,7 +289,12 @@ export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly start: Effect.Effect; - readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + // Resolves true when the active run fully finalized (child process exit + // observed) before the timeout, false when the timeout elapsed first — + // in that case the child may still be alive and still holding file + // handles. Callers that need the process gone (update install) must + // treat false as a failure instead of assuming the stop worked. + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; // Polls desiredRunning + the instance's own ready flag until the @@ -314,6 +348,14 @@ interface BackendManagerState { // Consecutive bounded/fatal preflight failures, reset on a clean or // unbounded-transient preflight. restartAttempt counts all restarts. readonly preflightFailureAttempt: number; + // Consecutive readiness timeouts (process alive, no candidate URL + // answering), reset when a run reaches ready or an external stop() runs. + readonly readinessFailureAttempt: number; + // Consecutive runs that spawned but exited before ever becoming ready. + // Reset when a run reaches ready, on external stop(), and on a fresh + // manual start — so the cap firing once doesn't permanently remove the + // retry budget. + readonly exitFailureAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; } @@ -325,6 +367,8 @@ const initialState: BackendManagerState = { active: Option.none(), restartAttempt: 0, preflightFailureAttempt: 0, + readinessFailureAttempt: 0, + exitFailureAttempt: 0, restartFiber: Option.none(), nextRunId: 1, }; @@ -563,14 +607,64 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - yield* waitForHttpReady({ + const processContext: BackendProcessContext = { executablePath: options.executablePath, entryPath: options.entryPath, cwd: options.cwd, httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( - Effect.tap(() => options.onReady?.() ?? Effect.void), + }; + const dedupeUrls = (urls: ReadonlyArray): ReadonlyArray => + urls.filter((url, index) => urls.findIndex((other) => other.href === url.href) === index); + const probeCandidates = (urls: ReadonlyArray, timeout: Duration.Duration) => + Effect.raceAll( + urls.map((url) => + waitForHttpReady({ ...processContext, httpBaseUrl: url, timeout }).pipe(Effect.as(url)), + ), + ); + const staticCandidates = dedupeUrls([ + options.httpBaseUrl, + ...(options.readinessProbeUrls ?? []), + ]); + const readinessTimeout = options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT; + yield* probeCandidates(staticCandidates, readinessTimeout).pipe( + // Every static candidate timed out. Before declaring the run + // unreachable, recover candidate origins from the backend's persisted + // runtime state (when the config wired a resolver) and probe those — + // a ready server on a reachable port must not present as a dead one + // just because the advertised host heuristic was wrong. + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => { + const failWithProbed = (probedUrls: ReadonlyArray) => + Effect.fail( + new BackendReadinessTimeoutError({ + ...processContext, + readinessUrl: error.readinessUrl, + timeoutMs: error.timeoutMs, + probedUrls, + cause: error.cause, + }), + ); + return ( + options.resolveReadinessFallbackUrls ?? Effect.succeed>([]) + ).pipe( + Effect.flatMap((fallbackUrls) => { + const fresh = dedupeUrls(fallbackUrls).filter( + (url) => !staticCandidates.some((candidate) => candidate.href === url.href), + ); + if (fresh.length === 0) { + return failWithProbed(staticCandidates); + } + return probeCandidates(fresh, READINESS_FALLBACK_PROBE_TIMEOUT).pipe( + Effect.catchTags({ + BackendReadinessTimeoutError: () => + failWithProbed([...staticCandidates, ...fresh]), + }), + ); + }), + ); + }, + }), + Effect.tap((readyBaseUrl) => options.onReady?.(readyBaseUrl) ?? Effect.void), Effect.catchTags({ BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, }), @@ -717,6 +811,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ready: false, config: Option.some(config.value), preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, + // A fresh manual start gets a fresh never-ready exit budget; a + // restart-loop start (desiredRunning already set) keeps the streak. + exitFailureAttempt: current.desiredRunning ? latest.exitFailureAttempt : 0, })); const preflightFailure = config.value.preflightFailure; @@ -813,55 +910,80 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ) { yield* mutex.withPermits(1)( Effect.gen(function* () { - const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = - yield* Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly isCurrentRun: boolean; - readonly nextState: BackendManagerState; - readonly pid: Option.Option; - readonly exitObserved: boolean; - readonly stopRequested: boolean; - readonly wasReady: boolean; - }, - BackendManagerState, - ] => { - const currentRun = Option.getOrUndefined(latest.active); - if (currentRun?.id !== runId) { - return [ - { - isCurrentRun: false, - nextState: latest, - pid: Option.none(), - exitObserved: false, - stopRequested: false, - wasReady: false, - }, - latest, - ] as const; - } - - const next = { - ...latest, - active: Option.none(), - ready: false, - }; + const { + isCurrentRun, + nextState, + pid, + exitObserved, + stopRequested, + wasReady, + exitFailureAttempt, + } = yield* Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly isCurrentRun: boolean; + readonly nextState: BackendManagerState; + readonly pid: Option.Option; + readonly exitObserved: boolean; + readonly stopRequested: boolean; + readonly wasReady: boolean; + // Some(n) when this exit counted toward the + // never-became-ready streak. + readonly exitFailureAttempt: Option.Option; + }, + BackendManagerState, + ] => { + const currentRun = Option.getOrUndefined(latest.active); + if (currentRun?.id !== runId) { return [ { - isCurrentRun: true, - nextState: next, - pid: currentRun.pid, - exitObserved: currentRun.exitObserved, - stopRequested: currentRun.stopRequested, - wasReady: latest.ready, + isCurrentRun: false, + nextState: latest, + pid: Option.none(), + exitObserved: false, + stopRequested: false, + wasReady: false, + exitFailureAttempt: Option.none(), }, - next, + latest, ] as const; - }, - ); + } + + const countsTowardExitCap = + currentRun.exitObserved && + !currentRun.stopRequested && + !latest.ready && + latest.desiredRunning; + const nextExitFailureAttempt = latest.ready + ? 0 + : countsTowardExitCap + ? latest.exitFailureAttempt + 1 + : latest.exitFailureAttempt; + const next = { + ...latest, + active: Option.none(), + ready: false, + exitFailureAttempt: nextExitFailureAttempt, + }; + return [ + { + isCurrentRun: true, + nextState: next, + pid: currentRun.pid, + exitObserved: currentRun.exitObserved, + stopRequested: currentRun.stopRequested, + wasReady: latest.ready, + exitFailureAttempt: countsTowardExitCap + ? Option.some(nextExitFailureAttempt) + : Option.none(), + }, + next, + ] as const; + }, + ); if (isCurrentRun) { yield* desktopTelemetryPublisher.removeControlSource(spec.id); @@ -880,6 +1002,36 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } if (isCurrentRun && nextState.desiredRunning) { + if ( + Option.isSome(exitFailureAttempt) && + exitFailureAttempt.value >= MAX_EXIT_FAILURE_ATTEMPTS + ) { + // The backend spawns fine but keeps dying before it ever + // answers a readiness probe. Silent restarts can't fix + // that — surface it exactly like an exhausted preflight + // (dialog + Windows fallback on the primary, inline error + // on the WSL secondary) instead of looping forever. + yield* logInstanceError( + "backend keeps exiting before becoming ready; surfacing", + { reason, attempt: exitFailureAttempt.value }, + ); + const shouldRestart = yield* ( + spec.onPreflightFailed?.({ + reason: `The backend exited ${exitFailureAttempt.value} times in a row before becoming ready (last exit: ${reason}).`, + fatal: false, + retryLimit: MAX_EXIT_FAILURE_ATTEMPTS, + }) ?? Effect.succeed(false) + ); + yield* Ref.update(state, (latest) => ({ + ...latest, + exitFailureAttempt: 0, + ...(shouldRestart ? {} : { desiredRunning: false }), + })); + if (shouldRestart) { + yield* scheduleRestart(reason); + } + return; + } yield* scheduleRestart(reason); } }), @@ -905,7 +1057,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, exitObserved: true, })), - onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* (readyBaseUrl) { const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -917,7 +1069,18 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( { ...latest, restartAttempt: 0, + readinessFailureAttempt: 0, + exitFailureAttempt: 0, ready: true, + // Rebind the advertised base URL to the candidate that + // actually answered, so consumers that read currentConfig + // (renderer bootstrap, window load) reach the backend on + // the transport that demonstrably works. + config: Option.map(latest.config, (current) => + current.httpBaseUrl.href === readyBaseUrl.href + ? current + : { ...current, httpBaseUrl: readyBaseUrl }, + ), }, ] as const; }); @@ -925,7 +1088,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } - yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + if (readyBaseUrl.href !== config.value.httpBaseUrl.href) { + yield* logInstanceWarning( + "backend became ready on a fallback URL; advertised base URL rebound", + { + advertisedUrl: config.value.httpBaseUrl.href, + readyUrl: readyBaseUrl.href, + }, + ); + } + yield* spec.onReady?.(readyBaseUrl) ?? Effect.void; }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { @@ -935,6 +1107,62 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* backendOutputLog.persistFailureSnapshot({ details: error.message, }); + const attempt = yield* Ref.modify(state, (latest) => { + const isCurrentRun = Option.getOrUndefined(latest.active)?.id === runId; + if (!isCurrentRun) { + return [Option.none(), latest] as const; + } + const next = latest.readinessFailureAttempt + 1; + return [ + Option.some(next), + { ...latest, readinessFailureAttempt: next }, + ] as const; + }); + if (Option.isNone(attempt)) { + return; + } + + if (attempt.value >= MAX_READINESS_FAILURE_ATTEMPTS) { + // A process that spawns fine but never answers any probe URL + // is not going to heal by silently cycling — surface the + // reason (dialog + Windows fallback on the primary, inline + // error on the WSL secondary) exactly like an exhausted + // preflight failure. + yield* logInstanceError( + "backend never became reachable; surfacing readiness failure", + { reason: error.message, attempt: attempt.value }, + ); + const shouldRestart = yield* ( + spec.onPreflightFailed?.({ + reason: error.message, + fatal: false, + retryLimit: MAX_READINESS_FAILURE_ATTEMPTS, + }) ?? Effect.succeed(false) + ); + yield* Ref.update(state, (latest) => ({ + ...latest, + readinessFailureAttempt: 0, + ...(shouldRestart ? {} : { desiredRunning: false }), + })); + } + // Terminate this run instead of leaving it in limbo (alive, + // never ready, never restarted). Closing the run scope kills + // the child; finalizeRun then schedules a restart when + // desiredRunning is still set. The close is forked into the + // parent scope because this fiber lives inside the run scope + // being closed. Mark the run stopRequested first: this exit is + // a deliberate termination, not a crash, so it must not count + // toward the never-ready exit cap (readiness timeouts have + // their own counter) — and the failure log was already + // captured above via persistFailureSnapshot. + yield* updateActiveRun(runId, (run) => ({ + ...run, + stopRequested: true, + })); + yield* Effect.forkIn( + Scope.close(runScope, Exit.void).pipe(Effect.ignore), + parentScope, + ); }, ), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), @@ -1037,6 +1265,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...latest, desiredRunning: false, ready: false, + readinessFailureAttempt: 0, + exitFailureAttempt: 0, active, restartFiber: Option.none>(), }, @@ -1053,13 +1283,17 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), }); - yield* Option.match(active, { - onNone: () => Effect.void, + return yield* Option.match(active, { + onNone: () => Effect.succeed(true), onSome: (run) => Effect.gen(function* () { const closed = yield* closeRun(run, parentScope, options); if (!closed) { - return; + yield* logInstanceWarning( + "backend stop timed out before the child process finalized; process may still be running", + { pid: Option.getOrNull(run.pid) }, + ); + return false; } const cleanup = yield* mutex.withPermits(1)( Ref.modify( @@ -1106,6 +1340,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (cleanup.shouldStart) { yield* start; } + return true; }), }); }); @@ -1127,7 +1362,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => stop().pipe(Effect.asVoid)); return { id: spec.id, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbe..09eee9ada13 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -33,7 +33,7 @@ function makeStubInstance( id, label: Effect.succeed(label), start: Effect.void, - stop: () => Effect.void, + stop: () => Effect.succeed(true), currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(snapshot), waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 9b85d1bb243..b5ee7576ea2 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -235,6 +235,30 @@ export const layer = Layer.effect( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; if (!fatal) { + const settings = yield* appSettings.get; + // Mirror resolvePrimary's own condition (`wslRequested = wslOnly && + // wslBackendEnabled`): the primary only resolves as WSL when BOTH + // flags are set. Checking wslOnly alone would misroute a + // wslOnly-but-disabled Windows primary into the WSL branch below — + // a misleading "WSL backend unavailable" dialog plus an unrelated + // in-memory WSL settings mutation. + const primaryResolvesAsWsl = settings.wslOnly && settings.wslBackendEnabled; + if (!primaryResolvesAsWsl) { + // The primary is already the Windows backend (the only non-fatal + // surfacing a Windows primary can hit is readiness exhaustion), + // so "fall back to Windows" is a no-op. Returning true would + // loop dialog + restart forever against a backend that never + // becomes reachable — surface once and stop instead. + yield* logBackendPoolWarning( + "primary backend never became reachable; stopping the restart loop", + { reason }, + ); + yield* electronDialog.showErrorBox( + "T3 Code backend is unavailable", + `${reason}\n\nRestart T3 Code to try again.`, + ); + return false; + } yield* logBackendPoolWarning( "primary WSL preflight retry window exhausted; using Windows for this launch", { reason }, diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..5f39e8b3f8f 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -37,7 +37,7 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { id: DesktopBackendManager.BackendInstanceId("wsl:default"), label: Effect.succeed("WSL (default distro)"), start: Effect.void, - stop: () => Effect.void, + stop: () => Effect.succeed(true), currentConfig: Effect.succeed(Option.some(readyWslConfig)), snapshot: Effect.succeed({ desiredRunning: true, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..cbc3a903a49 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -13,6 +13,7 @@ import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -21,6 +22,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; interface UpdatesHarnessOptions { readonly checkForUpdates?: Effect.Effect< @@ -29,7 +31,10 @@ interface UpdatesHarnessOptions { >; readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; - readonly stopBackend?: Effect.Effect; + readonly stopBackend?: Effect.Effect; + readonly backendConfig?: Option.Option; + readonly wslReleaseResult?: DesktopWslEnvironment.WslPathReleaseResult; + readonly platform?: NodeJS.Platform; readonly env?: Record; } @@ -37,6 +42,9 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; + let quitAndInstallCount = 0; + let backendStartCount = 0; + const wslReleaseCalls: Array<{ distro: string | null; windowsPath: string }> = []; let allowDowngrade = false; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; @@ -83,7 +91,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, + quitAndInstall: () => + Effect.sync(() => { + quitAndInstallCount += 1; + }), on: (eventName, listener) => Effect.acquireRelease( Effect.sync(() => { @@ -115,13 +126,15 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), - start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), + start: Effect.sync(() => { + backendStartCount += 1; + }), + stop: () => options.stopBackend ?? Effect.succeed(true), + currentConfig: Effect.succeed(options.backendConfig ?? Option.none()), snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), + desiredRunning: true, + ready: true, + activePid: Option.some(123), restartAttempt: 0, restartScheduled: false, }), @@ -132,7 +145,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", + platform: options.platform ?? "darwin", processArch: "x64", appVersion: "1.2.3", appPath: "/repo", @@ -171,6 +184,14 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { : DesktopAppSettings.layer; const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + ensureWindowsPathReleased: (input) => { + wslReleaseCalls.push(input); + return options.wslReleaseResult ?? "released"; + }, + }), + ), Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), @@ -191,6 +212,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { return { layer, checkCount: () => checkCount, + quitAndInstallCount: () => quitAndInstallCount, + backendStartCount: () => backendStartCount, + wslReleaseCalls, feedUrls: () => feedUrls, fullChangelog: () => fullChangelog, listenerCount: () => @@ -207,6 +231,29 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }; } +const makeWslBackendConfig = (): DesktopBackendManager.DesktopBackendStartConfig => ({ + executablePath: "wsl.exe", + args: [], + entryPath: "C:\\install\\resources\\app.asar.unpacked\\apps\\server\\dist\\bin.mjs", + cwd: "C:\\install", + env: {}, + extendEnv: false, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + host: "0.0.0.0", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "stdin", + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: true, + preflightFailure: Option.none(), + runningDistro: "Ubuntu", +}); + describe("DesktopUpdates", () => { it("preserves complete causes for update poller and event failures", () => { const cause = Cause.combine( @@ -607,4 +654,116 @@ describe("DesktopUpdates", () => { }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + + it.effect("aborts the install when a backend cannot be verified stopped", () => { + const harness = makeHarness({ + stopBackend: Effect.succeed(false), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + // The installer must never run against an install dir that a live + // backend still holds open. + assert.equal(harness.quitAndInstallCount(), 0); + // The app stays usable: the stopped backend is restarted and the + // quitting latch is released so a retry is possible. + assert.isAtLeast(harness.backendStartCount(), 1); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + + const failedState = yield* updates.getState; + assert.equal(failedState.errorContext, "install"); + assert.include(failedState.message ?? "", "did not shut down"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("aborts the install when WSL still holds files under the install directory", () => { + const harness = makeHarness({ + platform: "win32", + backendConfig: Option.some(makeWslBackendConfig()), + wslReleaseResult: "busy", + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + assert.equal(harness.quitAndInstallCount(), 0); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.wslReleaseCalls, [ + { distro: "Ubuntu", windowsPath: "/missing" }, + ]); + + const failedState = yield* updates.getState; + assert.equal(failedState.errorContext, "install"); + assert.include(failedState.message ?? "", "WSL"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("aborts the install when the WSL release check cannot verify (unknown)", () => { + const harness = makeHarness({ + platform: "win32", + backendConfig: Option.some(makeWslBackendConfig()), + wslReleaseResult: "unknown", + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.equal(harness.quitAndInstallCount(), 0); + + const failedState = yield* updates.getState; + assert.equal(failedState.errorContext, "install"); + assert.include(failedState.message ?? "", "could not verify"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("hands off to the installer after a verified stop and WSL release", () => { + const harness = makeHarness({ + platform: "win32", + backendConfig: Option.some(makeWslBackendConfig()), + wslReleaseResult: "released", + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.equal(harness.quitAndInstallCount(), 1); + assert.lengthOf(harness.wslReleaseCalls, 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e178..f03806f50de 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -20,6 +20,7 @@ import * as Scope from "effect/Scope"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; @@ -44,6 +45,14 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +// Per-backend budget for a verified stop before an install. Unlike the +// generic shutdown path, the install path must KNOW the child is gone: a +// surviving Windows backend child runs AS the app executable and reads +// through app.asar (real Windows file locks that make the NSIS apply +// half-fail), and a surviving WSL backend can interfere with the install +// from its side — so a timeout here aborts the install instead of +// proceeding. +const INSTALL_BACKEND_STOP_TIMEOUT = Duration.seconds(10); const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -247,6 +256,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -451,6 +461,29 @@ export const make = Effect.gen(function* () { { discard: true }, ); + // The install cannot proceed: a backend process (or a Linux-side process + // holding /mnt handles into the install directory) survived teardown, so + // running the installer now would half-apply the update and corrupt the + // install. Restart the backends that were running before the attempt (and + // only those — an instance that was already stopped stays stopped), + // surface the failure through the update state machine, and leave the + // downloaded update in place so the user can retry. + const abortInstall = ( + instancesToRestart: ReadonlyArray, + message: string, + ) => + Effect.gen(function* () { + yield* logUpdaterError(message, { stage: "pre-install-teardown" }); + yield* Effect.forEach( + instancesToRestart, + (instance) => instance.start.pipe(Effect.ignore), + { concurrency: "unbounded" }, + ); + yield* resetInstallAction; + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + return { accepted: true, completed: false }; + }); + const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( @@ -470,14 +503,86 @@ export const make = Effect.gen(function* () { // means quitAndInstall's app.quit() exits before the pool's // scope cascade has a chance to run its stop finalizer, so the // WSL child gets hard-killed by the OS instead of receiving - // SIGTERM + grace. Stops run concurrently with the same 5s - // budget the primary had on its own. + // SIGTERM + grace. Stops run concurrently, and each stop must + // report the child fully finalized — the installer replaces files + // the children hold open, so "probably stopped" is not enough. const instances = yield* pool.list; - yield* Effect.forEach( + const stopResults = yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => + Effect.gen(function* () { + // Capture config and run-state before stop: the WSL release + // check below needs the distro, and the abort path must only + // restart instances that were actually running — a backend that + // was already stopped (e.g. after a preflight failure) must not + // be resurrected by a failed install attempt. + const instanceConfig = yield* instance.currentConfig; + const beforeStop = yield* instance.snapshot; + const wasRunning = beforeStop.desiredRunning || Option.isSome(beforeStop.activePid); + const finalized = yield* instance.stop({ timeout: INSTALL_BACKEND_STOP_TIMEOUT }); + return { instance, instanceConfig, wasRunning, finalized }; + }), { concurrency: "unbounded" }, ); + const previouslyRunning = stopResults + .filter((result) => result.wasRunning) + .map((result) => result.instance); + + const unstopped = stopResults.filter((result) => result.wasRunning && !result.finalized); + if (unstopped.length > 0) { + return yield* abortInstall( + previouslyRunning, + `Update install aborted: backend ${unstopped + .map((result) => `"${result.instance.id}"`) + .join(", ")} did not shut down in time. Try the update again.`, + ); + } + + // A stopped wsl.exe relay does not guarantee the Linux-side server + // (or helpers it spawned, e.g. cloudflared) exited with it. Whether a + // lingering Linux holder blocks Windows-side file replacement depends + // on the WSL/DrvFs version (on WSL 2.7 mirrored, plain fds and mmaps + // measurably do NOT block it) — but a backend that is still alive can + // also rewrite state mid-install, so verify release defensively + // instead of assuming the relay kill was enough. + if (environment.isPackaged && environment.platform === "win32") { + const installDir = environment.path.dirname(environment.resourcesPath); + for (const result of stopResults) { + const instanceConfig = Option.getOrUndefined(result.instanceConfig); + if (instanceConfig?.executablePath !== "wsl.exe") { + continue; + } + const released = yield* wslEnvironment.ensureWindowsPathReleased({ + distro: instanceConfig.runningDistro ?? null, + windowsPath: installDir, + }); + if (released === "busy") { + return yield* abortInstall( + previouslyRunning, + `Update install aborted: processes inside WSL (${result.instance.id}) still hold files under ${installDir}. Close them (or run "wsl --shutdown") and try the update again.`, + ); + } + if (released === "unknown") { + // The check ran against a distro that was hosting this backend + // moments ago but could not verify release (spawn failure, + // timeout, path translation failure). Handles may well still be + // open — abort rather than risk the half-applied install this + // guard exists to prevent. A machine whose WSL layer is truly + // broken recovers on the next launch: preflight falls back to + // Windows and no WSL config enters the pool. + return yield* abortInstall( + previouslyRunning, + `Update install aborted: could not verify that WSL (${result.instance.id}) released the files under ${installDir}. Run "wsl --shutdown" and try the update again.`, + ); + } + yield* logUpdaterInfo("verified WSL released the install directory", { + instanceId: result.instance.id, + installDir, + }); + } + } + + yield* logUpdaterInfo("backends stopped and verified; handing off to installer"); yield* electronWindow.destroyAll; yield* electronUpdater.quitAndInstall({ isSilent: true, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adcfb..337bbb03821 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -27,7 +27,7 @@ function makeStubInstance(input: { id: input.id, label: Effect.succeed(input.label), start: input.start ?? Effect.void, - stop: () => Effect.void, + stop: () => Effect.succeed(true), currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(input.snapshot), waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e368..1dec215ce1b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -9,8 +9,16 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; + import { + buildEnsurePathReleasedScript, buildWslNodeEnvPreamble, + ensureWindowsPathReleasedImpl, + getDistroIpsImpl, + getNetworkingModeImpl, + readServerRuntimeStateImpl, DesktopWslDistroListError, formatMissingToolsReason, formatNodePtyProbeFailureReason, @@ -259,3 +267,285 @@ describe("formatMissingToolsReason", () => { expect(reason).not.toContain("nvm"); }); }); + +const makeArgvSpawner = ( + handler: (argv: readonly string[]) => { readonly stdout?: string; readonly exitCode?: number }, +) => + ChildProcessSpawner.make((command) => { + const argv = command._tag === "StandardCommand" ? [command.command, ...command.args] : []; + const result = handler(argv); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + result.exitCode === undefined + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(result.exitCode === undefined), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("getDistroIpsImpl", () => { + it.effect("returns every IPv4 in reported order, including CGNAT and bridge addresses", () => + Effect.gen(function* () { + // Tailscale-in-WSL regression shape: the CGNAT address comes first and + // the actually-reachable mirrored address second. All must be returned; + // selection is the caller's job. + const ips = yield* getDistroIpsImpl("Ubuntu"); + expect(ips).toEqual(["100.108.4.21", "192.168.127.5", "172.17.0.1"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner(() => ({ + stdout: "100.108.4.21 192.168.127.5 172.17.0.1 fd7c::1\n", + exitCode: 0, + })), + ), + ), + ); + + it.effect("returns an empty list when the command fails", () => + Effect.gen(function* () { + const ips = yield* getDistroIpsImpl(null); + expect(ips).toEqual([]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner(() => ({ exitCode: 1 })), + ), + ), + ); +}); + +describe("getNetworkingModeImpl", () => { + const modeSpawner = (stdout: string | undefined, exitCode?: number) => + makeArgvSpawner((argv) => { + expect(argv).toContain("wslinfo"); + expect(argv).toContain("--networking-mode"); + return exitCode === undefined && stdout === undefined + ? {} + : { stdout: stdout ?? "", exitCode: exitCode ?? 0 }; + }); + + it.effect("parses mirrored mode", () => + Effect.gen(function* () { + expect(yield* getNetworkingModeImpl("Ubuntu")).toEqual("mirrored"); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, modeSpawner("mirrored\n")), + ), + ); + + it.effect("parses nat mode", () => + Effect.gen(function* () { + expect(yield* getNetworkingModeImpl(null)).toEqual("nat"); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, modeSpawner("nat\n"))), + ); + + it.effect("reports unknown when wslinfo is missing or fails", () => + Effect.gen(function* () { + expect(yield* getNetworkingModeImpl(null)).toEqual("unknown"); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, modeSpawner(undefined, 127)), + ), + ); + + it.effect("reports unknown for unrecognized output", () => + Effect.gen(function* () { + expect(yield* getNetworkingModeImpl(null)).toEqual("unknown"); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, modeSpawner("bridged\n")), + ), + ); +}); + +describe("readServerRuntimeStateImpl", () => { + it.effect("parses the persisted runtime state from the requested state dir", () => + Effect.gen(function* () { + const state = yield* readServerRuntimeStateImpl("Ubuntu", "userdata"); + expect(Option.isSome(state)).toBe(true); + if (Option.isSome(state)) { + expect(state.value.port).toBe(3773); + expect(state.value.origin).toBe("http://127.0.0.1:3773"); + expect(state.value.host).toBe("0.0.0.0"); + } + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner((argv) => { + expect(argv.join(" ")).toContain(".t3/userdata/server-runtime.json"); + expect(argv.join(" ")).not.toContain(".t3/dev/"); + return { + stdout: + '{"version":1,"pid":4242,"host":"0.0.0.0","port":3773,"origin":"http://127.0.0.1:3773","startedAt":"2026-08-09T17:15:00Z"}\n', + exitCode: 0, + }; + }), + ), + ), + ); + + it.effect("returns none for a missing or unparsable file", () => + Effect.gen(function* () { + expect(Option.isNone(yield* readServerRuntimeStateImpl(null, "dev"))).toBe(true); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner(() => ({ stdout: "", exitCode: 0 })), + ), + ), + ); +}); + +describe("ensureWindowsPathReleasedImpl", () => { + const releaseSpawner = (shOutput: { readonly stdout: string; readonly exitCode: number }) => + makeArgvSpawner((argv) => + argv.includes("wslpath") + ? { stdout: "/mnt/c/Users/test/AppData/Local/Programs/t3code\n", exitCode: 0 } + : shOutput, + ); + + it.effect("reports released when no Linux process holds the path", () => + Effect.gen(function* () { + const result = yield* ensureWindowsPathReleasedImpl({ + distro: "Ubuntu", + windowsPath: "C:\\Users\\test\\AppData\\Local\\Programs\\t3code", + }); + expect(result).toBe("released"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + releaseSpawner({ stdout: "RELEASED\n", exitCode: 0 }), + ), + ), + ); + + it.effect("reports busy when holders survive SIGTERM and SIGKILL", () => + Effect.gen(function* () { + const result = yield* ensureWindowsPathReleasedImpl({ + distro: "Ubuntu", + windowsPath: "C:\\Users\\test\\AppData\\Local\\Programs\\t3code", + }); + expect(result).toBe("busy"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + releaseSpawner({ stdout: "BUSY 4242 4243\n", exitCode: 1 }), + ), + ), + ); + + it.effect("runs the holder scan as root so it can see and kill non-user-owned holders", () => + Effect.gen(function* () { + // /proc//{cwd,exe,fd,maps} are readable only by the process owner + // and root, so an unprivileged scan is blind to holders owned by other + // users (e.g. a root process with cwd in the install dir) and would + // wrongly report RELEASED. The probe must run as root. + const scanArgvs: string[][] = []; + const result = yield* ensureWindowsPathReleasedImpl({ + distro: "Ubuntu", + windowsPath: "C:\\Users\\test\\AppData\\Local\\Programs\\t3code", + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner((argv) => { + if (argv.includes("wslpath")) { + return { stdout: "/mnt/c/Users/test/AppData/Local/Programs/t3code\n", exitCode: 0 }; + } + scanArgvs.push([...argv]); + return { stdout: "RELEASED\n", exitCode: 0 }; + }), + ), + ); + expect(result).toBe("released"); + expect(scanArgvs).toHaveLength(1); + const argv = scanArgvs[0]!; + const separator = argv.indexOf("--"); + const userFlag = argv.indexOf("-u"); + // `-u root` must appear before the `--` command separator. + expect(userFlag).toBeGreaterThanOrEqual(0); + expect(argv[userFlag + 1]).toBe("root"); + expect(userFlag).toBeLessThan(separator); + }), + ); + + it.effect("reports unknown when the release-check spawn fails against a reachable distro", () => + Effect.gen(function* () { + // wslpath just succeeded, so the distro was reachable moments ago — a + // release-script spawn failure must read as "unverified" (abort), not + // as "WSL is gone". + const result = yield* ensureWindowsPathReleasedImpl({ + distro: "Ubuntu", + windowsPath: "C:\\Users\\test\\AppData\\Local\\Programs\\t3code", + }); + expect(result).toBe("unknown"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const argv = command._tag === "StandardCommand" ? [command.command, ...command.args] : []; + if (argv.includes("wslpath")) { + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("/mnt/c/install\n")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + } + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: "wsl.exe", + description: "wsl.exe missing", + }), + ); + }), + ), + ), + ); + + it.effect("reports unknown when the path cannot be translated", () => + Effect.gen(function* () { + const result = yield* ensureWindowsPathReleasedImpl({ + distro: null, + windowsPath: "C:\\install", + }); + expect(result).toBe("unknown"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeArgvSpawner((argv) => + argv.includes("wslpath") ? { stdout: "", exitCode: 1 } : { stdout: "", exitCode: 0 }, + ), + ), + ), + ); + + it("kills before scanning again and excludes itself from the holder scan", () => { + const script = buildEnsurePathReleasedScript("'/mnt/c/install dir'"); + expect(script).toContain("cd /"); + expect(script).toContain('[ "$pid" = "$self" ] && continue'); + expect(script).toContain("kill $holders"); + expect(script).toContain("kill -9 $holders"); + expect(script.indexOf("kill $holders")).toBeLessThan(script.indexOf("kill -9 $holders")); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500..e0db8ce2cbd 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -23,6 +23,10 @@ const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); const USER_HOME_TIMEOUT = Duration.seconds(5); +const WSLINFO_TIMEOUT = Duration.seconds(5); +// TERM grace (6 × 0.5s) + KILL grace (4 × 0.5s) + /proc scans + wslpath; +// generous so a slow 9p mount can't turn a genuine release into "unknown". +const PATH_RELEASE_TIMEOUT = Duration.seconds(30); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; @@ -53,6 +57,40 @@ export class DesktopWslDistroListError extends Schema.TaggedErrorClass Effect.Effect>; - // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter - // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's - // httpBaseUrl so the renderer can reach it without relying on wslhost's - // localhost→WSL automatic forwarding, which is flaky in practice - // (the backend can be listening for 30+ seconds before wslhost starts - // forwarding 127.0.0.1:port to WSL-side localhost). - readonly getDistroIp: (distro: string | null) => Effect.Effect>; + // Resolves every IPv4 address the distro has bound (from `hostname -I`), + // in reported order. Callers must NOT treat position as meaning — the + // ordering shifts whenever Tailscale/Docker/VPN adapters appear inside + // the distro. Used to build the WSL backend's readiness-probe candidate + // set next to loopback; the first candidate that answers wins. + readonly getDistroIps: (distro: string | null) => Effect.Effect>; + // Authoritative networking mode via `wslinfo --networking-mode`. + readonly getNetworkingMode: (distro: string | null) => Effect.Effect; + // Reads the backend's persisted runtime state (server-runtime.json) + // from inside the distro. Used as a readiness fallback: when every + // probe candidate times out, the file's origin recovers the URL the + // server actually advertises. + readonly readServerRuntimeState: ( + distro: string | null, + stateDir: WslServerStateDir, + ) => Effect.Effect>; + // Terminates any Linux-side process still holding files (cwd, exe, open + // fd, or mapped file) under the given Windows path via /mnt, then + // verifies release. Called before an update install as a defensive + // guarantee that nothing WSL-side outlives teardown: whether 9p/DrvFs + // handles block Windows-side replacement varies by WSL version, but a + // surviving backend can interfere with an install either way. + readonly ensureWindowsPathReleased: (input: { + readonly distro: string | null; + readonly windowsPath: string; + }) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, windowsRepoRoot: string, @@ -682,16 +739,18 @@ const windowsToWslPathImpl = ( const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; -const getDistroIpImpl = ( +export const getDistroIpsImpl = ( distro: string | null, -): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => +): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // `hostname -I` prints a space-separated list of all non-loopback - // IPs the distro has bound. The first entry on the WSL2 default - // network is always the eth0 vEthernet address Windows can reach - // directly (no wslhost forwarding required). + // `hostname -I` prints a space-separated list of all non-loopback IPs + // the distro has bound. The ordering is NOT a contract: Tailscale, + // docker0, WireGuard and friends inject addresses at arbitrary + // positions (a WSL-side tailscaled puts a CGNAT 100.64/10 address + // first). Return every IPv4 and let the caller probe candidates + // instead of trusting position. const command = ChildProcess.make( "wsl.exe", [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"], @@ -706,17 +765,206 @@ const getDistroIpImpl = ( const handle = yield* spawner.spawn(command); const stdoutBytes = yield* Stream.runCollect(handle.stdout); const exitCode = yield* handle.exitCode; - if ((exitCode as unknown as number) !== 0) return Option.none(); + if ((exitCode as unknown as number) !== 0) return [] as ReadonlyArray; const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); - const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part)); - return candidate ? Option.some(candidate) : Option.none(); + return raw.split(/\s+/).filter((part) => IPV4_PATTERN.test(part)); }), ).pipe( Effect.timeoutOption(USER_HOME_TIMEOUT), + Effect.map(Option.getOrElse((): ReadonlyArray => [])), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + +export const getNetworkingModeImpl = ( + distro: string | null, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // wslinfo ships with WSL (verified on 2.6.3.0 and 2.7.3.0) and is the + // first-party answer for the networking mode; parsing `hostname -I` + // ordering to infer mirrored networking is what broke Tailscale-in-WSL + // hosts. A missing wslinfo (older WSL) lands in the "unknown" branch. + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(distro), "--", "wslinfo", "--networking-mode"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + const handle = yield* spawner.spawn(command); + const stdoutBytes = yield* Stream.runCollect(handle.stdout); + const exitCode = yield* handle.exitCode; + if ((exitCode as unknown as number) !== 0) return "unknown" as WslNetworkingMode; + const raw = decodeUtf8(concatChunks(stdoutBytes)).trim().toLowerCase(); + if (raw === "mirrored") return "mirrored" as WslNetworkingMode; + if (raw === "nat") return "nat" as WslNetworkingMode; + return "unknown" as WslNetworkingMode; + }), + ).pipe( + Effect.timeoutOption(WSLINFO_TIMEOUT), + Effect.map(Option.getOrElse((): WslNetworkingMode => "unknown")), + Effect.orElseSucceed((): WslNetworkingMode => "unknown"), + ); + +// State-dir flavor mirroring the server's deriveServerPaths: backends +// launched with --dev-url resolve state under ~/.t3/dev, packaged ones +// under ~/.t3/userdata. The caller says which one the backend it spawned +// uses — reading "whichever exists" would return a stale packaged file on +// machines that have run both. +export type WslServerStateDir = "userdata" | "dev"; + +// The backend runs with the distro user's own $HOME (the bootstrap omits +// t3Home so Linux state never lands on /mnt/c). +const readServerRuntimeStateScript = (stateDir: WslServerStateDir): string => + `cat "$HOME/.t3/${stateDir}/server-runtime.json" 2>/dev/null || true`; + +export const readServerRuntimeStateImpl = ( + distro: string | null, + stateDir: WslServerStateDir, +): Effect.Effect< + Option.Option, + never, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(distro), "--", "sh", "-c", readServerRuntimeStateScript(stateDir)], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + const handle = yield* spawner.spawn(command); + const stdoutBytes = yield* Stream.runCollect(handle.stdout); + yield* handle.exitCode; + const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); + if (raw.length === 0) return Option.none(); + return yield* decodeWslServerRuntimeState(raw).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + }), + ).pipe( + Effect.timeoutOption(WSLINFO_TIMEOUT), Effect.map(Option.flatten), - Effect.orElseSucceed(() => Option.none()), + Effect.orElseSucceed(() => Option.none()), ); +// Finds every Linux process holding the target directory via cwd, exe, an +// open fd, or a mapped file; SIGTERMs, then SIGKILLs stragglers, then +// reports. `cd /` first so the probe shell itself (spawned with the +// desktop's install-dir cwd mapped through /mnt) never counts as a holder. +// Matching is anchored: descendants must match "$dir/" and the directory +// itself must match as an exact readlink line — a bare substring match +// would classify /mnt/c/application as a holder of /mnt/c/app and kill +// unrelated processes. +export const buildEnsurePathReleasedScript = (quotedLinuxPath: string): string => `cd / +dir=${quotedLinuxPath} +self=$$ +find_holders() { + for p in /proc/[0-9]*; do + pid=\${p#/proc/} + [ "$pid" = "$self" ] && continue + [ "$pid" = "1" ] && continue + if { readlink "$p/cwd" "$p/exe" "$p"/fd/* 2>/dev/null; cat "$p/maps" 2>/dev/null; } 2>/dev/null | grep -qF "$dir/" || + readlink "$p/cwd" "$p/exe" "$p"/fd/* 2>/dev/null | grep -qxF "$dir"; then + printf '%s ' "$pid" + fi + done +} +holders=$(find_holders) +[ -z "$holders" ] && { echo RELEASED; exit 0; } +kill $holders 2>/dev/null +for i in 1 2 3 4 5 6; do + sleep 0.5 + holders=$(find_holders) + [ -z "$holders" ] && { echo RELEASED; exit 0; } +done +kill -9 $holders 2>/dev/null +for i in 1 2 3 4; do + sleep 0.5 + holders=$(find_holders) + [ -z "$holders" ] && { echo RELEASED; exit 0; } +done +echo "BUSY $holders" +exit 1 +`; + +export const ensureWindowsPathReleasedImpl = (input: { + readonly distro: string | null; + readonly windowsPath: string; +}): Effect.Effect => + Effect.gen(function* () { + // The caller only asks about a distro that was running a backend moments + // ago, so a failed path translation means "distro reachable but the + // check can't run" — unverifiable ("unknown"), not "no VM" — and the + // caller must not treat it as a pass. + const linuxPath = yield* windowsToWslPathImpl(input.distro, input.windowsPath); + if (Option.isNone(linuxPath)) { + return "unknown" as WslPathReleaseResult; + } + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // Run the probe as root (-u root; WSL grants this with no password, + // gated by the Windows host). find_holders reads /proc//{cwd,exe, + // fd,maps}, which the kernel exposes only to the process owner and + // root: as the unprivileged distro user the scan gets EACCES for any + // process it does not own, so a root- or other-user-owned holder of + // the install dir is invisible and the check wrongly reports RELEASED. + // Running as root lets the scan SEE every holder and lets its kills + // land on holders it does not own. find_holders is already scoped to + // processes that actually reference the install dir, so this cannot + // reach unrelated processes. + // Script goes via stdin: wsl.exe re-escapes command-line args on the + // way into Linux, which mangles quoted paths with spaces. + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(input.distro), "-u", "root", "--", "sh"], + { + stdin: Stream.encodeText( + Stream.make(buildEnsurePathReleasedScript(shellQuote(linuxPath.value))), + ), + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + // A spawn failure here is NOT evidence that WSL is gone: wslpath just + // succeeded against this distro, so the VM was reachable moments + // ago. Fall through to "unknown" (via the catch below) and let the + // caller abort. + const handle = yield* spawner.spawn(command); + const stdoutBytes = yield* Stream.runCollect(handle.stdout); + const exitCode = yield* handle.exitCode; + const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); + if ((exitCode as unknown as number) === 0 && raw.includes("RELEASED")) { + return "released" as WslPathReleaseResult; + } + if (raw.includes("BUSY")) { + return "busy" as WslPathReleaseResult; + } + return "unknown" as WslPathReleaseResult; + }), + ).pipe( + Effect.timeoutOption(PATH_RELEASE_TIMEOUT), + Effect.map(Option.getOrElse((): WslPathReleaseResult => "unknown")), + Effect.orElseSucceed((): WslPathReleaseResult => "unknown"), + ); + }); + const getUserHomeImpl = ( distro: string | null, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => @@ -773,7 +1021,16 @@ export interface DesktopWslEnvironmentTestStub { readonly distroListError?: DesktopWslDistroListError; readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option; readonly getUserHome?: (distro: string | null) => Option.Option; - readonly getDistroIp?: (distro: string | null) => Option.Option; + readonly getDistroIps?: (distro: string | null) => ReadonlyArray; + readonly getNetworkingMode?: (distro: string | null) => WslNetworkingMode; + readonly readServerRuntimeState?: ( + distro: string | null, + stateDir: WslServerStateDir, + ) => Option.Option; + readonly ensureWindowsPathReleased?: (input: { + readonly distro: string | null; + readonly windowsPath: string; + }) => WslPathReleaseResult; readonly ensureNodePty?: ( distro: string | null, windowsRepoRoot: string, @@ -795,7 +1052,12 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { windowsToWslPath: (distro, windowsPath) => Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), - getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), + getDistroIps: (distro) => Effect.succeed(stub.getDistroIps?.(distro) ?? []), + getNetworkingMode: (distro) => Effect.succeed(stub.getNetworkingMode?.(distro) ?? "unknown"), + readServerRuntimeState: (distro, stateDir) => + Effect.succeed(stub.readServerRuntimeState?.(distro, stateDir) ?? Option.none()), + ensureWindowsPathReleased: (input) => + Effect.succeed(stub.ensureWindowsPathReleased?.(input) ?? "unknown"), ensureNodePty: (distro, windowsRepoRoot, options) => Effect.succeed( stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? { @@ -859,8 +1121,26 @@ export const layer = Layer.effect( return resolved; }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); - const getDistroIp = (distro: string | null) => - provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); + const getDistroIps = (distro: string | null) => + provideSpawner(getDistroIpsImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIps")); + + const getNetworkingMode = (distro: string | null) => + provideSpawner(getNetworkingModeImpl(distro)).pipe( + Effect.withSpan("desktop.wsl.getNetworkingMode"), + ); + + const readServerRuntimeState = (distro: string | null, stateDir: WslServerStateDir) => + provideSpawner(readServerRuntimeStateImpl(distro, stateDir)).pipe( + Effect.withSpan("desktop.wsl.readServerRuntimeState"), + ); + + const ensureWindowsPathReleased = (input: { + readonly distro: string | null; + readonly windowsPath: string; + }) => + provideSpawner(ensureWindowsPathReleasedImpl(input)).pipe( + Effect.withSpan("desktop.wsl.ensureWindowsPathReleased"), + ); const probeDistros = provideSpawner(probeWslDistros).pipe( Effect.withSpan("desktop.wsl.probeDistros"), @@ -877,7 +1157,10 @@ export const layer = Layer.effect( provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), windowsToWslPath, getUserHome, - getDistroIp, + getDistroIps, + getNetworkingMode, + readServerRuntimeState, + ensureWindowsPathReleased, ensureNodePty: (distro, windowsRepoRoot, options) => provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a30b6d4a90a..dcd0fb92ca1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1837,6 +1837,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + // Build stamp for the desktop's startup install-integrity check. It lives + // inside apps/server/dist so the Windows asarUnpack globs place it under + // app.asar.unpacked; comparing its version against the asar's own version + // detects a half-applied update (locked files silently skipped by the + // silent NSIS installer). See apps/desktop/src/app/DesktopInstallIntegrity.ts. + yield* fs.writeFileString( + path.join(stageAppDir, "apps/server/dist/desktop-build-manifest.json"), + `${JSON.stringify({ version: appVersion })}\n`, + ); yield* stageResourceMonitor({ repoRoot, stageResourcesDir,