From ba3b2887e467c2a83e550cd9ad4fc3c96db6be73 Mon Sep 17 00:00:00 2001 From: Robbie Date: Mon, 26 Oct 2026 03:22:37 -0600 Subject: [PATCH 1/4] fix(web): WSL backends are not listed as skipped in Update everything A WSL backend cannot ask its desktop app to update, so remote clients read it as an old desktop app and listed it as skipped with "needs a newer desktop app". It updates with the desktop app that hosts it, so the server now advertises desktopSecondaryBackend and the dialog omits those environments. --- .../src/environment/ServerEnvironment.test.ts | 26 ++++++++++++++ .../src/environment/ServerEnvironment.ts | 4 ++- .../sidebar/updateEverything.logic.test.ts | 35 ++++++++++++++++++- .../sidebar/updateEverything.logic.ts | 5 ++- packages/contracts/src/environment.ts | 2 ++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..a68063c42978b 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -122,6 +122,32 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); + it.effect("marks a desktop backend without the update control fd as a secondary", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + for (const desktopTelemetryControlFd of [5, undefined]) { + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide( + ServerConfig.layer({ ...config, mode: "desktop", desktopTelemetryControlFd }), + ), + ), + ), + ); + const primary = desktopTelemetryControlFd !== undefined; + expect(descriptor.capabilities.desktopAppUpdate).toBe(primary ? true : undefined); + expect(descriptor.capabilities.desktopSecondaryBackend).toBe(primary ? undefined : true); + } + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4678aff95b736..3f7c61a4d33c9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -199,9 +199,10 @@ export const make = Effect.gen(function* () { }); const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null; // Desktop and server ship together; a control fd guarantees requestDesktopUpdate support. - + // Only the primary gets one, so a desktop backend without it is a secondary such as WSL. const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + const desktopSecondaryBackend = serverSelfUpdate === "desktop-managed" && !desktopAppUpdate; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -264,6 +265,7 @@ export const make = Effect.gen(function* () { ? { serverSelfUpdateProgress: true } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(desktopSecondaryBackend ? { desktopSecondaryBackend: true } : {}), serverBrowser: true, }, }; diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 2bcfd7530b0db..b8c351cf95edc 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { + EnvironmentId, + type ServerConfig, + type ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -39,6 +43,31 @@ function machine( }; } +function withCapabilities( + base: UpdateEverythingMachine, + capabilities: Partial, +): UpdateEverythingMachine { + return { + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + capabilities: { ...base.serverConfig!.environment.capabilities, ...capabilities }, + }, + }, + }; +} + +const secondary = (id: string, overrides: Partial = {}) => + withCapabilities(machine(id, "0.0.40", "desktop-managed", overrides), { + desktopAppUpdate: false, + desktopSecondaryBackend: true, + }); + +const oldDesktop = (id: string) => + withCapabilities(machine(id, "0.0.40", "desktop-managed"), { desktopAppUpdate: false }); + describe("collectServerUpdateTargets", () => { it("updates remote servers that can move to the target and names every skipped one", () => { const { targets, skipped } = collectServerUpdateTargets( @@ -47,6 +76,9 @@ describe("collectServerUpdateTargets", () => { machine("service", "0.0.40", "boot-service"), machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), + secondary("other-wsl"), + secondary("offline-wsl", { connected: false }), + oldDesktop("old-desktop"), machine("offline", "0.0.40", "boot-service", { connected: false }), machine("never-connected", "0.0.40", "boot-service", { serverConfig: null }), machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), @@ -71,6 +103,7 @@ describe("collectServerUpdateTargets", () => { ]); expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ ["nightly-service", "on the nightly channel"], + ["old-desktop", "needs a newer desktop app"], ["offline", "not connected"], ["never-connected", "not connected"], ["no-scope", "no permission"], diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index d4fb711dfe6ab..e0436abd80080 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -61,6 +61,7 @@ export interface SkippedMachine { } /** Skip current and app-hosted servers; the latter update with the app and would relaunch mid-run. + * Desktop secondaries such as WSL update with their own app, so they are omitted, not skipped. * Desktop-managed servers use their own feed. */ export function collectServerUpdateTargets( machines: ReadonlyArray, @@ -70,7 +71,9 @@ export function collectServerUpdateTargets( const skipped: SkippedMachine[] = []; for (const machine of machines) { const config = machine.serverConfig; - if (machine.hostedByThisApp) continue; + if (machine.hostedByThisApp || config?.environment.capabilities.desktopSecondaryBackend) { + continue; + } if ( config !== null && resolveVersionMismatch(config.environment.serverVersion, targetVersion) === null diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7b47d88202c7d..70192ef460d95 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -130,6 +130,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** A desktop app's secondary backend, such as WSL, which updates only with that app. */ + desktopSecondaryBackend: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ From a530af9cdb99477e25f33515a8e9e2aceaa12771 Mon Sep 17 00:00:00 2001 From: Robbie Date: Mon, 26 Oct 2026 15:03:21 -0600 Subject: [PATCH 2/4] fix(desktop): WSL backends name the desktop environment that hosts them Replace the inferred desktopSecondaryBackend flag with an explicit desktopHost on the environment descriptor. The desktop app always marks its WSL backend as desktop-hosted through the bootstrap, and adds the primary's environment id when it can read it once the primary is ready. Clients know which environment owns a WSL backend instead of guessing from a missing update channel, and a failed id lookup still keeps WSL out of the Update everything skip list. --- .../DesktopBackendConfiguration.test.ts | 10 +- .../backend/DesktopBackendConfiguration.ts | 5 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 95 ++++++++++++++++++- apps/desktop/src/wsl/DesktopWslBackend.ts | 33 ++++++- apps/server/src/cli/config.ts | 2 + apps/server/src/config.ts | 3 +- .../src/environment/ServerEnvironment.test.ts | 18 ++-- .../src/environment/ServerEnvironment.ts | 5 +- .../sidebar/updateEverything.logic.test.ts | 23 ++++- .../sidebar/updateEverything.logic.ts | 2 +- packages/contracts/src/desktopBootstrap.ts | 10 +- packages/contracts/src/environment.ts | 4 +- 12 files changed, 187 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2f..a001c65d4d57a 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -328,7 +329,11 @@ describe("DesktopBackendConfiguration", () => { const observedDistros: Array = []; const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolveWsl({ port: 5000, distro: null }); + return yield* configuration.resolveWsl({ + port: 5000, + distro: null, + hostEnvironmentId: EnvironmentId.make("windows-primary"), + }); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -368,6 +373,9 @@ describe("DesktopBackendConfiguration", () => { ); assert.equal(config.runningDistro, "Ubuntu"); + assert.deepEqual(config.bootstrap.desktopHost, { + environmentId: EnvironmentId.make("windows-primary"), + }); assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); assert.isTrue(Option.isNone(config.preflightFailure)); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ca69f5d025899..a8db7fb1e0374 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; +import type { EnvironmentId } from "@t3tools/contracts"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -47,6 +48,7 @@ export class DesktopBackendConfiguration extends Context.Service< readonly resolveWsl: (input: { readonly port: number; readonly distro: string | null; + readonly hostEnvironmentId?: EnvironmentId | undefined; }) => Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError @@ -548,6 +550,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl input: SharedBootstrapInput & { readonly port: number; readonly distro: string | null; + readonly hostEnvironmentId?: EnvironmentId | undefined; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -575,6 +578,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, + desktopHost: + input.hostEnvironmentId === undefined ? {} : { environmentId: input.hostEnvironmentId }, ...buildObservabilityFragment(input.observabilitySettings), }; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630c..f17d0d512ef63 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -3,7 +3,9 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { EnvironmentId } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; @@ -22,6 +24,7 @@ function makeStubInstance(input: { readonly label: string; readonly snapshot: DesktopBackendSnapshot; readonly start?: Effect.Effect; + readonly ready?: boolean; }): DesktopBackendPool.DesktopBackendInstance { return { id: input.id, @@ -30,7 +33,7 @@ function makeStubInstance(input: { stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(input.snapshot), - waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(input.ready ?? false), }; } @@ -83,6 +86,22 @@ const layerNet = Layer.succeed(NetService.NetService, { findAvailablePort: (preferred) => Effect.succeed(preferred), } satisfies NetService.NetService["Service"]); +const layerHttpClient = (respond: (url: string) => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, respond(request.url))), + ), + ); + +const primaryDescriptor = { + environmentId: "primary-env", + label: "Windows", + platform: { os: "windows", arch: "x64" }, + serverVersion: "0.0.50", + capabilities: { repositoryIdentity: true }, +}; + describe("DesktopWslBackend", () => { it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; @@ -178,6 +197,80 @@ describe("DesktopWslBackend", () => { Layer.provideMerge(layerBackendConfiguration), Layer.provideMerge(layerServerExposure), Layer.provideMerge(layerNet), + Layer.provideMerge(layerHttpClient(() => new Response(null, { status: 500 }))), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + ), + ), + ); + }); + + it.effect.each([ + { name: "a ready primary", ready: true, status: 200, expected: "primary-env" }, + { name: "a primary that never becomes ready", ready: false, status: 200, expected: undefined }, + { name: "a failed descriptor request", ready: true, status: 500, expected: undefined }, + ])("passes the primary environment id to WSL for $name", ({ ready, status, expected }) => { + let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; + const resolvedHostIds: Array = []; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + ready, + }); + const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + get: () => Effect.succeed(Option.none()), + list: Effect.succeed([primary]), + primary: Effect.succeed(primary), + register: (spec) => + Effect.sync(() => { + registeredSpec = spec; + return makeStubInstance({ id: spec.id, label: "WSL", snapshot: idleSnapshot }); + }), + unregister: () => Effect.void, + } satisfies DesktopBackendPool.DesktopBackendPool["Service"]); + const layerConfiguration = Layer.succeed( + DesktopBackendConfiguration.DesktopBackendConfiguration, + { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + resolveWsl: (input) => { + resolvedHostIds.push(input.hostEnvironmentId); + return Effect.die("stop after resolving the host id"); + }, + currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], + ); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + yield* backend.reconcile; + if (registeredSpec === undefined) throw new Error("Expected WSL backend registration"); + yield* Effect.exit(registeredSpec.configResolve); + assert.deepEqual(resolvedHostIds, [ + expected === undefined ? undefined : EnvironmentId.make(expected), + ]); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(layerPool), + Layer.provideMerge(layerConfiguration), + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(layerNet), + Layer.provideMerge( + layerHttpClient((url) => + url === "http://127.0.0.1:3773/.well-known/t3/environment" + ? Response.json(primaryDescriptor, { status }) + : new Response(null, { status: 404 }), + ), + ), Layer.provideMerge( DesktopAppSettings.layerTest({ ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index e77d896be2469..1bcfd8230affc 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,12 +1,15 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import * as NetService from "@t3tools/shared/Net"; +import { HttpClient } from "effect/http"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; @@ -18,6 +21,7 @@ import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; +const PRIMARY_READY_TIMEOUT = Duration.seconds(60); export class DesktopWslBackend extends Context.Service< DesktopWslBackend, @@ -65,6 +69,7 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); @@ -89,6 +94,24 @@ export const layer = Layer.effect( }), ); + // Lets clients attribute the WSL backend to this app's primary. On failure WSL still + // starts and is still marked as desktop-hosted, just without the primary's id. + const resolveHostEnvironmentId = Effect.gen(function* () { + const primary = yield* pool.primary; + if (!(yield* primary.waitForReady(PRIMARY_READY_TIMEOUT))) return undefined; + const { httpBaseUrl } = yield* serverExposure.backendConfig; + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: httpBaseUrl.href, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + return descriptor.environmentId; + }).pipe( + Effect.catch((error) => + logWslBackendWarning("could not read the primary environment id", { + error: error.message, + }).pipe(Effect.as(undefined)), + ), + ); + const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { readonly distro: string | null; }) { @@ -119,7 +142,15 @@ export const layer = Layer.effect( .register({ id: targetId, label: Effect.succeed(buildLabel(input.distro)), - configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), + configResolve: resolveHostEnvironmentId.pipe( + Effect.flatMap((hostEnvironmentId) => + configuration.resolveWsl({ + port: allocatedPort, + distro: input.distro, + hostEnvironmentId, + }), + ), + ), onPreflightFailed: (failure) => Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), onStateDirOwned: () => diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00621e332deb9..44a94d230f0a4 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -371,6 +371,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; const resourceMonitorPath = bootstrap?.resourceMonitorPath; + const desktopHost = bootstrap?.desktopHost; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), @@ -501,6 +502,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct desktopBrowserFd, desktopBrowserControlFd, resourceMonitorPath, + ...(desktopHost === undefined ? {} : { desktopHost }), autoBootstrapProjectFromCwd, logWebSocketEvents, tailscaleServeEnabled, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91d443308776c..b4b2de262a3fe 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import type { OtlpEndpointSource } from "@t3tools/contracts"; +import type { DesktopHost, OtlpEndpointSource } from "@t3tools/contracts"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, @@ -102,6 +102,7 @@ export class ServerConfig extends Context.Service< readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; readonly resourceMonitorPath?: string | undefined; + readonly desktopHost?: DesktopHost | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a68063c42978b..3a49d13909139 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -122,13 +122,17 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); - it.effect("marks a desktop backend without the update control fd as a secondary", () => + it.effect("publishes the desktop host environment only for a hosted backend", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped(); const config = yield* makeServerConfig(baseDir); yield* fs.makeDirectory(config.stateDir, { recursive: true }); - for (const desktopTelemetryControlFd of [5, undefined]) { + for (const desktopHost of [ + { environmentId: EnvironmentId.make("windows-primary") }, + {}, + undefined, + ]) { const descriptor = yield* Effect.gen(function* () { const environment = yield* ServerEnvironment.ServerEnvironment; return yield* environment.getDescriptor; @@ -136,15 +140,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { Effect.provide( ServerEnvironment.layer.pipe( Layer.provide(layerEmptySecretStore), - Layer.provide( - ServerConfig.layer({ ...config, mode: "desktop", desktopTelemetryControlFd }), - ), + Layer.provide(ServerConfig.layer({ ...config, mode: "desktop", desktopHost })), ), ), ); - const primary = desktopTelemetryControlFd !== undefined; - expect(descriptor.capabilities.desktopAppUpdate).toBe(primary ? true : undefined); - expect(descriptor.capabilities.desktopSecondaryBackend).toBe(primary ? undefined : true); + expect(descriptor.desktopHost).toEqual(desktopHost); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 3f7c61a4d33c9..973a0afe3328a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -199,10 +199,9 @@ export const make = Effect.gen(function* () { }); const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null; // Desktop and server ship together; a control fd guarantees requestDesktopUpdate support. - // Only the primary gets one, so a desktop backend without it is a secondary such as WSL. + const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; - const desktopSecondaryBackend = serverSelfUpdate === "desktop-managed" && !desktopAppUpdate; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -214,6 +213,7 @@ export const make = Effect.gen(function* () { }, serverVersion: packageJson.version, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + ...(serverConfig.desktopHost === undefined ? {} : { desktopHost: serverConfig.desktopHost }), capabilities: { repositoryIdentity: true, connectionProbe: true, @@ -265,7 +265,6 @@ export const make = Effect.gen(function* () { ? { serverSelfUpdateProgress: true } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), - ...(desktopSecondaryBackend ? { desktopSecondaryBackend: true } : {}), serverBrowser: true, }, }; diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index b8c351cf95edc..ac9638f256540 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -59,11 +59,27 @@ function withCapabilities( }; } -const secondary = (id: string, overrides: Partial = {}) => - withCapabilities(machine(id, "0.0.40", "desktop-managed", overrides), { +const secondary = ( + id: string, + overrides: Partial = {}, + desktopHost: { readonly environmentId?: EnvironmentId } = { + environmentId: EnvironmentId.make("windows-primary"), + }, +) => { + const base = withCapabilities(machine(id, "0.0.40", "desktop-managed", overrides), { desktopAppUpdate: false, - desktopSecondaryBackend: true, }); + return { + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + desktopHost, + }, + }, + }; +}; const oldDesktop = (id: string) => withCapabilities(machine(id, "0.0.40", "desktop-managed"), { desktopAppUpdate: false }); @@ -78,6 +94,7 @@ describe("collectServerUpdateTargets", () => { machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), secondary("other-wsl"), secondary("offline-wsl", { connected: false }), + secondary("unattributed-wsl", {}, {}), oldDesktop("old-desktop"), machine("offline", "0.0.40", "boot-service", { connected: false }), machine("never-connected", "0.0.40", "boot-service", { serverConfig: null }), diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index e0436abd80080..a81cc508976b6 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -71,7 +71,7 @@ export function collectServerUpdateTargets( const skipped: SkippedMachine[] = []; for (const machine of machines) { const config = machine.serverConfig; - if (machine.hostedByThisApp || config?.environment.capabilities.desktopSecondaryBackend) { + if (machine.hostedByThisApp || config?.environment.desktopHost !== undefined) { continue; } if ( diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..214ebe716e6de 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -1,10 +1,17 @@ import * as Schema from "effect/Schema"; -import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; // A supervisor must not restart a server refused by the state-directory owner. export const SERVER_EXIT_CODE_STATE_DIR_OWNED = 78; +/** Marks a desktop app's secondary backend, such as WSL, which updates only with that app. */ +export const DesktopHost = Schema.Struct({ + // Best effort: absent when the primary's id could not be read before this backend started. + environmentId: Schema.optionalKey(EnvironmentId), +}); +export type DesktopHost = typeof DesktopHost.Type; + export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), noBrowser: Schema.Boolean, @@ -27,6 +34,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopBrowserFd: Schema.optionalKey(PositiveInt), desktopBrowserControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), + desktopHost: Schema.optionalKey(DesktopHost), }); export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70192ef460d95..bcc461db8b8b8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -8,6 +8,7 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { DesktopHost } from "./desktopBootstrap.ts"; import { SourceControlProviderKind } from "./sourceControl.ts"; export const ORCHESTRATION_PROTOCOL_VERSION = 2; @@ -130,8 +131,6 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), - /** A desktop app's secondary backend, such as WSL, which updates only with that app. */ - desktopSecondaryBackend: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ @@ -146,6 +145,7 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ serverVersion: TrimmedNonEmptyString, orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, + desktopHost: Schema.optionalKey(DesktopHost), }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; From c3c81b1b63bd948a41ab1d803c092e1961943dc3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 06:05:26 +0000 Subject: [PATCH 3/4] fix(web): keep uncovered WSL updates visible --- .../DesktopBackendConfiguration.test.ts | 13 ++- .../backend/DesktopBackendConfiguration.ts | 13 ++- .../desktop/src/wsl/DesktopWslBackend.test.ts | 95 +------------------ apps/desktop/src/wsl/DesktopWslBackend.ts | 33 +------ .../sidebar/updateEverything.logic.test.ts | 50 +++++++++- .../sidebar/updateEverything.logic.ts | 34 ++++++- 6 files changed, 97 insertions(+), 141 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index a001c65d4d57a..3d53c8a63e373 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -329,11 +329,13 @@ describe("DesktopBackendConfiguration", () => { const observedDistros: Array = []; const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolveWsl({ - port: 5000, - distro: null, - hostEnvironmentId: EnvironmentId.make("windows-primary"), - }); + const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(environment.stateDir, "environment-id"), + "windows-primary\n", + ); + return yield* configuration.resolveWsl({ port: 5000, distro: null }); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -769,6 +771,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.host, "0.0.0.0"); assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.notProperty(config.bootstrap, "desktopTelemetryFd"); + assert.deepEqual(config.bootstrap.desktopHost, {}); assert.notProperty(config.bootstrap, "resourceMonitorPath"); assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 7c21f37500217..a472c87bb2dc6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,6 +1,6 @@ import * as NodeOS from "node:os"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -48,7 +48,6 @@ export class DesktopBackendConfiguration extends Context.Service< readonly resolveWsl: (input: { readonly port: number; readonly distro: string | null; - readonly hostEnvironmentId?: EnvironmentId | undefined; }) => Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError @@ -838,7 +837,15 @@ export const make = Effect.gen(function* () { resolveWsl: Effect.fnUntraced( function* (input) { const shared = yield* sharedInputs; - return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( + // Ownership is optional; WSL must start even before the primary creates its identity. + const hostEnvironmentId = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe( + Effect.map((value) => value.trim()), + Effect.map((value) => (value ? EnvironmentId.make(value) : undefined)), + Effect.orElseSucceed(() => undefined), + ); + return yield* resolveWslStartConfig({ ...shared, ...input, hostEnvironmentId }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index f17d0d512ef63..8468a9538630c 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -3,9 +3,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { EnvironmentId } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; @@ -24,7 +22,6 @@ function makeStubInstance(input: { readonly label: string; readonly snapshot: DesktopBackendSnapshot; readonly start?: Effect.Effect; - readonly ready?: boolean; }): DesktopBackendPool.DesktopBackendInstance { return { id: input.id, @@ -33,7 +30,7 @@ function makeStubInstance(input: { stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed(input.snapshot), - waitForReady: (_timeout: Duration.Duration) => Effect.succeed(input.ready ?? false), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), }; } @@ -86,22 +83,6 @@ const layerNet = Layer.succeed(NetService.NetService, { findAvailablePort: (preferred) => Effect.succeed(preferred), } satisfies NetService.NetService["Service"]); -const layerHttpClient = (respond: (url: string) => Response) => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, respond(request.url))), - ), - ); - -const primaryDescriptor = { - environmentId: "primary-env", - label: "Windows", - platform: { os: "windows", arch: "x64" }, - serverVersion: "0.0.50", - capabilities: { repositoryIdentity: true }, -}; - describe("DesktopWslBackend", () => { it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; @@ -197,80 +178,6 @@ describe("DesktopWslBackend", () => { Layer.provideMerge(layerBackendConfiguration), Layer.provideMerge(layerServerExposure), Layer.provideMerge(layerNet), - Layer.provideMerge(layerHttpClient(() => new Response(null, { status: 500 }))), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: false, - }), - ), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - ), - ), - ); - }); - - it.effect.each([ - { name: "a ready primary", ready: true, status: 200, expected: "primary-env" }, - { name: "a primary that never becomes ready", ready: false, status: 200, expected: undefined }, - { name: "a failed descriptor request", ready: true, status: 500, expected: undefined }, - ])("passes the primary environment id to WSL for $name", ({ ready, status, expected }) => { - let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; - const resolvedHostIds: Array = []; - const primary = makeStubInstance({ - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: "Windows", - snapshot: primarySnapshot, - ready, - }); - const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { - get: () => Effect.succeed(Option.none()), - list: Effect.succeed([primary]), - primary: Effect.succeed(primary), - register: (spec) => - Effect.sync(() => { - registeredSpec = spec; - return makeStubInstance({ id: spec.id, label: "WSL", snapshot: idleSnapshot }); - }), - unregister: () => Effect.void, - } satisfies DesktopBackendPool.DesktopBackendPool["Service"]); - const layerConfiguration = Layer.succeed( - DesktopBackendConfiguration.DesktopBackendConfiguration, - { - resolvePrimary: Effect.die("unexpected resolvePrimary"), - resolvePrimaryLabel: Effect.succeed("Windows"), - resolveWsl: (input) => { - resolvedHostIds.push(input.hostEnvironmentId); - return Effect.die("stop after resolving the host id"); - }, - currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"), - } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], - ); - - return Effect.gen(function* () { - const backend = yield* DesktopWslBackend.DesktopWslBackend; - yield* backend.reconcile; - if (registeredSpec === undefined) throw new Error("Expected WSL backend registration"); - yield* Effect.exit(registeredSpec.configResolve); - assert.deepEqual(resolvedHostIds, [ - expected === undefined ? undefined : EnvironmentId.make(expected), - ]); - }).pipe( - Effect.provide( - DesktopWslBackend.layer.pipe( - Layer.provideMerge(layerPool), - Layer.provideMerge(layerConfiguration), - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(layerNet), - Layer.provideMerge( - layerHttpClient((url) => - url === "http://127.0.0.1:3773/.well-known/t3/environment" - ? Response.json(primaryDescriptor, { status }) - : new Response(null, { status: 404 }), - ), - ), Layer.provideMerge( DesktopAppSettings.layerTest({ ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 1bcfd8230affc..e77d896be2469 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,15 +1,12 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; -import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import * as NetService from "@t3tools/shared/Net"; -import { HttpClient } from "effect/http"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; @@ -21,7 +18,6 @@ import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; -const PRIMARY_READY_TIMEOUT = Duration.seconds(60); export class DesktopWslBackend extends Context.Service< DesktopWslBackend, @@ -69,7 +65,6 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; - const httpClient = yield* HttpClient.HttpClient; // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); @@ -94,24 +89,6 @@ export const layer = Layer.effect( }), ); - // Lets clients attribute the WSL backend to this app's primary. On failure WSL still - // starts and is still marked as desktop-hosted, just without the primary's id. - const resolveHostEnvironmentId = Effect.gen(function* () { - const primary = yield* pool.primary; - if (!(yield* primary.waitForReady(PRIMARY_READY_TIMEOUT))) return undefined; - const { httpBaseUrl } = yield* serverExposure.backendConfig; - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: httpBaseUrl.href, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); - return descriptor.environmentId; - }).pipe( - Effect.catch((error) => - logWslBackendWarning("could not read the primary environment id", { - error: error.message, - }).pipe(Effect.as(undefined)), - ), - ); - const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { readonly distro: string | null; }) { @@ -142,15 +119,7 @@ export const layer = Layer.effect( .register({ id: targetId, label: Effect.succeed(buildLabel(input.distro)), - configResolve: resolveHostEnvironmentId.pipe( - Effect.flatMap((hostEnvironmentId) => - configuration.resolveWsl({ - port: allocatedPort, - distro: input.distro, - hostEnvironmentId, - }), - ), - ), + configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), onPreflightFailed: (failure) => Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), onStateDirOwned: () => diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index bc15e7cbcc99f..1788aff0d49d9 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -127,6 +127,9 @@ describe("collectServerUpdateTargets", () => { ]); expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ ["nightly-service", "on the nightly channel"], + ["other-wsl", "update its owning desktop app"], + ["offline-wsl", "not connected"], + ["unattributed-wsl", "update its owning desktop app"], ["old-desktop", "needs a newer desktop app"], ["offline", "not connected"], ["never-connected", "not connected"], @@ -136,6 +139,47 @@ describe("collectServerUpdateTargets", () => { ]); }); + it.each([ + { + name: "updating", + owner: machine("windows-primary", "0.0.40", "desktop-managed"), + covered: true, + }, + { name: "absent", owner: undefined, covered: false }, + { + name: "offline", + owner: machine("windows-primary", "0.0.40", "desktop-managed", { connected: false }), + covered: false, + }, + { + name: "denied", + owner: machine("windows-primary", "0.0.40", "desktop-managed", { canMaintain: false }), + covered: false, + }, + { + name: "current", + owner: machine("windows-primary", "0.0.50", "desktop-managed"), + covered: false, + }, + { + name: "not a desktop", + owner: machine("windows-primary", "0.0.40", "boot-service"), + covered: false, + }, + ])("only omits a secondary when its owner is updating: $name", ({ owner, covered }) => { + const { targets, skipped } = collectServerUpdateTargets( + [secondary("wsl"), ...(owner ? [owner] : [])], + "0.0.50", + true, + ); + expect(targets.some((target) => target.environmentId === "wsl")).toBe(false); + expect(skipped.find((entry) => entry.environmentId === "wsl")).toEqual( + covered + ? undefined + : { environmentId: "wsl", label: "wsl", reason: "update its owning desktop app" }, + ); + }); + it("waits for npm only for hosts that install from it or when the release may lack archives", () => { const hosts = [ machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), @@ -307,8 +351,9 @@ describe("collectRestartingMachines", () => { withContinuation("opted-out", {}, false), machine("old", "0.0.40", "boot-service"), withContinuation("providers-only"), + secondary("wsl"), ]; - const servers = ["alvin", "opted-out", "old"].map((id) => ({ + const servers = ["alvin", "opted-out", "old", "windows-primary"].map((id) => ({ environmentId: EnvironmentId.make(id), })); @@ -317,10 +362,11 @@ describe("collectRestartingMachines", () => { { environmentId: "alvin", continuesThreads: true }, { environmentId: "opted-out", continuesThreads: false }, { environmentId: "old", continuesThreads: false }, + { environmentId: "wsl", continuesThreads: false }, ]); expect( collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), - ).toEqual(["alvin", "opted-out", "old"]); + ).toEqual(["alvin", "opted-out", "old", "wsl"]); }); }); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index ad2411614aaa4..4a7ed4bba312b 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -41,10 +41,15 @@ export function collectRestartingMachines( const updated = new Set(servers.map((server) => server.environmentId)); return machines.flatMap((machine) => { const config = machine.serverConfig; + const owner = config?.environment.desktopHost?.environmentId; if ( config === null || !machine.connected || - !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp)) + !( + updated.has(machine.environmentId) || + (owner !== undefined && updated.has(owner)) || + (installsLocal && machine.hostedByThisApp) + ) ) { return []; } @@ -84,9 +89,7 @@ export function collectServerUpdateTargets( const waitForNpm: ServerUpdateTarget[] = []; for (const machine of machines) { const config = machine.serverConfig; - if (machine.hostedByThisApp || config?.environment.desktopHost !== undefined) { - continue; - } + if (machine.hostedByThisApp) continue; if ( config !== null && resolveVersionMismatch(config.environment.serverVersion, targetVersion) === null @@ -100,6 +103,10 @@ export function collectServerUpdateTargets( skip(unavailable ?? "not connected"); continue; } + if (config.environment.desktopHost !== undefined) { + skip("update its owning desktop app"); + continue; + } const selfUpdate = resolveServerSelfUpdateCapability(config); const desktopAppUpdate = supportsDesktopAppUpdate(config); if (selfUpdate === null) { @@ -136,7 +143,24 @@ export function collectServerUpdateTargets( waitForNpm.push(target); } } - return { targets, skipped, waitForNpm }; + const updatingDesktops = new Set( + targets + .filter((target) => target.selfUpdate === "desktop-managed") + .map((target) => target.environmentId), + ); + const coveredSecondaries = new Set( + machines + .filter((machine) => { + const owner = machine.serverConfig?.environment.desktopHost?.environmentId; + return owner !== undefined && updatingDesktops.has(owner); + }) + .map((machine) => machine.environmentId), + ); + return { + targets, + skipped: skipped.filter((machine) => !coveredSecondaries.has(machine.environmentId)), + waitForNpm, + }; } function unavailableReason(machine: UpdateEverythingMachine): string | null { From 5cb12b4e79bf4860f389be6fef9bb3a47831864d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 06:59:33 +0000 Subject: [PATCH 4/4] fix(desktop): wait for owner identity before starting WSL --- .../desktop/src/wsl/DesktopWslBackend.test.ts | 19 ++++++++++++++----- apps/desktop/src/wsl/DesktopWslBackend.ts | 4 ++++ .../sidebar/updateEverything.logic.test.ts | 2 -- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630c..af2b849bf607a 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -88,11 +88,19 @@ describe("DesktopWslBackend", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; let registeredWsl: DesktopBackendPool.DesktopBackendInstance | undefined; let startCount = 0; - const primary = makeStubInstance({ - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: "Windows", - snapshot: primarySnapshot, - }); + let primaryReadyChecked = false; + const primary = { + ...makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }), + waitForReady: () => + Effect.sync(() => { + primaryReadyChecked = true; + return true; + }), + }; const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { get: (id) => Effect.succeed( @@ -104,6 +112,7 @@ describe("DesktopWslBackend", () => { primary: Effect.succeed(primary), register: (spec) => Effect.sync(() => { + assert.isTrue(primaryReadyChecked); registeredSpec = spec; registeredWsl = makeStubInstance({ id: spec.id, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index e77d896be2469..23da5d294c9f0 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -92,6 +93,9 @@ export const layer = Layer.effect( const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { readonly distro: string | null; }) { + const primary = yield* pool.primary; + // Readiness creates the owner identity; keep WSL available if the primary cannot start. + yield* primary.waitForReady(Duration.seconds(60)); const primaryConfig = yield* serverExposure.backendConfig; const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( Effect.provideService(NetService.NetService, net), diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 9d90921e09897..66284d8a50c5c 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -177,8 +177,6 @@ describe("collectServerUpdateTargets", () => { : { environmentId: "wsl", label: "wsl", reason: "update its owning desktop app" }, ); }); - - }); describe("collectChannelSwitchTargets", () => {