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,