Przeglądaj źródła

fix(desktop): few WSL bugs (#31095)

Filip 3 miesięcy temu
rodzic
commit
65a3f7f749

+ 10 - 5
packages/app/src/components/dialog-select-server.tsx

@@ -509,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void;
     resetEdit()
   })
 
-  async function handleRemove(url: ServerConnection.Key) {
-    tabs.removeServer(url)
-    server.remove(url)
-    if ((await platform.getDefaultServer?.()) === url) {
-      void platform.setDefaultServer?.(null)
+  async function handleRemove(key: ServerConnection.Key) {
+    try {
+      if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
+      tabs.removeServer(key)
+      server.remove(key)
+      if ((await platform.getDefaultServer?.()) === key) {
+        await setDefault(null)
+      }
+    } catch (err) {
+      showRequestError(language, err)
     }
   }
 

+ 11 - 11
packages/app/src/wsl/dialog-add-server.tsx

@@ -71,6 +71,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
     if (!distro) return null
     return current()?.opencodeChecks[distro] ?? null
   })
+  const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
+  const distroReady = createMemo(() => {
+    const probe = selectedProbe()
+    if (!probe || !selectedDistro()) return false
+    if (selectedInstalled()?.version === 1) return false
+    return probe.canExecute && probe.hasBash && probe.hasCurl
+  })
+  const opencodeReady = createMemo(() => {
+    const check = opencodeCheck()
+    return !!check?.resolvedPath && !check.error
+  })
   const distroWarningProbe = createMemo(() => {
     const probe = selectedProbe()
     if (!probe) return null
@@ -106,17 +117,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
     const job = current()?.job
     return job?.kind === "install-opencode" && job.distro === selectedDistro()
   })
-  const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
-  const distroReady = createMemo(() => {
-    const probe = selectedProbe()
-    if (!probe || !selectedDistro()) return false
-    if (selectedInstalled()?.version === 1) return false
-    return probe.canExecute && probe.hasBash && probe.hasCurl
-  })
-  const opencodeReady = createMemo(() => {
-    const check = opencodeCheck()
-    return !!check?.resolvedPath && !check.error
-  })
   const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
   const addDisabled = createMemo(() => {
     const job = current()?.job

+ 1 - 5
packages/app/src/wsl/settings.tsx

@@ -76,11 +76,7 @@ export function WslServerSettings(props: {
   }))
 
   const remove = (key: ServerConnection.Key) => {
-    if (!api) return
-    request.mutate(async () => {
-      await api.removeServer(key)
-      await props.controller.handleRemove(key)
-    })
+    request.mutate(() => props.controller.handleRemove(key))
   }
 
   return (

+ 0 - 4
packages/desktop/src/main/constants.ts

@@ -4,8 +4,4 @@ type Channel = "dev" | "beta" | "prod"
 const raw = import.meta.env.OPENCODE_CHANNEL
 export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
 
-export const SETTINGS_STORE = "opencode.settings"
-export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
-export const WSL_SERVERS_KEY = "wslServers"
-export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
 export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

+ 7 - 3
packages/desktop/src/main/index.ts

@@ -145,8 +145,10 @@ const main = Effect.gen(function* () {
       })
     },
     {
-      log: (message, meta) => logger.log(message, meta),
-      error: (message, meta) => logger.error(message, meta),
+      logger: {
+        log: (message, meta) => logger.log(message, meta),
+        error: (message, meta) => logger.error(message, meta),
+      },
     },
   )
   const stopSidecars = async () => {
@@ -327,7 +329,9 @@ const main = Effect.gen(function* () {
       password,
     })
 
-    void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
+    if (process.platform === "win32") {
+      void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
+    }
 
     yield* Effect.promise(() => health.wait).pipe(
       Effect.timeout("30 seconds"),

+ 3 - 2
packages/desktop/src/main/server.ts

@@ -2,9 +2,10 @@ import { dirname, join } from "node:path"
 import { fileURLToPath } from "node:url"
 import { app, utilityProcess } from "electron"
 import type { Details } from "electron"
-import { DEFAULT_SERVER_URL_KEY } from "./constants"
+import { getLogger } from "./logging"
 import { getUserShell, loadShellEnv } from "./shell-env"
 import { getStore } from "./store"
+import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
 
 export type HealthCheck = { wait: Promise<void> }
 
@@ -43,7 +44,7 @@ export function setDefaultServerUrl(url: string | null) {
 export function preferAppEnv(userDataPath: string) {
   const shell = process.platform === "win32" ? null : getUserShell()
   Object.assign(process.env, {
-    ...(shell ? loadShellEnv(shell) : null),
+    ...(shell ? loadShellEnv(shell, getLogger()) : null),
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
     OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
     OPENCODE_CLIENT: "desktop",

+ 4 - 3
packages/desktop/src/main/shell-env.ts

@@ -1,11 +1,13 @@
 import { spawnSync } from "node:child_process"
 import { userInfo } from "node:os"
 import { basename } from "node:path"
-import { getLogger } from "./logging"
 
 const TIMEOUT = 5_000
 
 type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
+type ShellEnvLogger = {
+  log: (message: string) => void
+}
 
 export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
   const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
@@ -65,8 +67,7 @@ export function isNushell(shell: string) {
   return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
 }
 
-export function loadShellEnv(shell: string) {
-  const logger = getLogger()
+export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
   if (isNushell(shell)) {
     logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
     return null

+ 4 - 0
packages/desktop/src/main/store-keys.ts

@@ -0,0 +1,4 @@
+export const SETTINGS_STORE = "opencode.settings"
+export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
+export const WSL_SERVERS_KEY = "wslServers"
+export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"

+ 3 - 3
packages/desktop/src/main/store.ts

@@ -1,7 +1,7 @@
 import Store from "electron-store"
-import { app } from "electron"
+import electron from "electron"
 
-import { SETTINGS_STORE } from "./constants"
+import { SETTINGS_STORE } from "./store-keys"
 
 const cache = new Map<string, Store>()
 
@@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) {
   if (cached) return cached
   const next = new Store({
     name,
-    cwd: app.getPath("userData"),
+    cwd: electron.app.getPath("userData"),
     fileExtension: "",
     accessPropertiesByDotNotation: false,
   })

+ 1 - 1
packages/desktop/src/main/windows.ts

@@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
 import { dirname, isAbsolute, join, relative, resolve } from "node:path"
 import { fileURLToPath, pathToFileURL } from "node:url"
 import type { TitlebarTheme } from "../preload/types"
-import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
 import { exportDebugLogs, write as writeLog } from "./logging"
 import { getStore } from "./store"
+import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
 import { createUnresponsiveSampler } from "./unresponsive"
 
 const root = dirname(fileURLToPath(import.meta.url))

+ 43 - 0
packages/desktop/src/main/wsl/ipc.ts

@@ -2,8 +2,14 @@ import { app, ipcMain } from "electron"
 import type { IpcMainInvokeEvent } from "electron"
 import type { WslServersController } from "./servers"
 import { requireWslIpcString } from "./policy"
+import type { WslServersState } from "../../preload/types"
 
 export function registerWslIpcHandlers(controller: WslServersController) {
+  if (process.platform !== "win32") {
+    registerUnavailableWslIpcHandlers()
+    return
+  }
+
   const subscriptions = new Map<number, () => void>()
   const unsubscribe = (id: number) => {
     const off = subscriptions.get(id)
@@ -62,3 +68,40 @@ export function registerWslIpcHandlers(controller: WslServersController) {
     controller.startServer(requireWslIpcString("server id", id)),
   )
 }
+
+function registerUnavailableWslIpcHandlers() {
+  const unavailable = () => {
+    throw new Error("WSL is only available on Windows")
+  }
+  const state = (): WslServersState => ({
+    runtime: {
+      available: false,
+      version: null,
+      error: "WSL is only available on Windows",
+    },
+    installed: [],
+    online: [],
+    distroProbes: {},
+    opencodeChecks: {},
+    pendingRestart: false,
+    servers: [],
+    job: null,
+  })
+
+  ipcMain.handle("wsl-servers-subscribe", (event) => {
+    event.sender.send("wsl-servers-event", { type: "state", state: state() })
+  })
+  ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
+  ipcMain.handle("wsl-servers-get-state", () => state())
+  ipcMain.handle("wsl-servers-probe-runtime", unavailable)
+  ipcMain.handle("wsl-servers-refresh-distros", unavailable)
+  ipcMain.handle("wsl-servers-install-wsl", unavailable)
+  ipcMain.handle("wsl-servers-install-distro", unavailable)
+  ipcMain.handle("wsl-servers-probe-distro", unavailable)
+  ipcMain.handle("wsl-servers-probe-opencode", unavailable)
+  ipcMain.handle("wsl-servers-install-opencode", unavailable)
+  ipcMain.handle("wsl-servers-open-terminal", unavailable)
+  ipcMain.handle("wsl-servers-add", unavailable)
+  ipcMain.handle("wsl-servers-remove", unavailable)
+  ipcMain.handle("wsl-servers-start", unavailable)
+}

+ 74 - 0
packages/desktop/src/main/wsl/servers.test.ts

@@ -6,6 +6,10 @@ import {
   pollWslHealth,
   wslServerIdsToStartOnInitialize,
 } from "./startup"
+import { createWslServersController, type WslServerConfig } from "./servers"
+
+let persistedServers: WslServerConfig[] = []
+let releaseOpencodeResolve: (() => void) | undefined
 
 test("starts every configured WSL server on initialization", () => {
   expect(
@@ -91,3 +95,73 @@ test("derives a required Windows restart from the post-install runtime probe", (
   expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
   expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
 })
+
+test("ignores stale background OpenCode checks after removing a WSL server", async () => {
+  persistedServers = []
+  releaseOpencodeResolve = undefined
+  const controller = createWslServersController(
+    "1.16.2",
+    async () => ({
+      listener: {
+        stop: () => undefined,
+        onExit: () => undefined,
+      },
+      url: "http://127.0.0.1:4096",
+      username: "opencode",
+      password: "secret",
+    }),
+    testControllerOptions(),
+  )
+
+  await controller.addServer("Debian")
+  await waitFor(() => !!releaseOpencodeResolve)
+  await controller.removeServer("wsl:Debian")
+  releaseOpencodeResolve?.()
+  await new Promise((resolve) => setTimeout(resolve, 0))
+
+  expect(controller.getState().servers).toEqual([])
+  expect(controller.getState().opencodeChecks).toEqual({})
+})
+
+test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
+  persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
+  releaseOpencodeResolve = undefined
+  const controller = createWslServersController(
+    "1.16.2",
+    async () => new Promise<never>(() => undefined),
+    testControllerOptions(),
+  )
+
+  await controller.initialize()
+  await waitFor(() => !!releaseOpencodeResolve)
+  await controller.removeServer("wsl:Debian")
+  releaseOpencodeResolve?.()
+  await new Promise((resolve) => setTimeout(resolve, 0))
+
+  expect(controller.getState().servers).toEqual([])
+  expect(controller.getState().opencodeChecks).toEqual({})
+})
+
+async function waitFor(check: () => boolean) {
+  for (let attempt = 0; attempt < 20; attempt++) {
+    if (check()) return
+    await new Promise((resolve) => setTimeout(resolve, 0))
+  }
+  throw new Error("Timed out waiting for condition")
+}
+
+function testControllerOptions() {
+  return {
+    readServers: () => persistedServers,
+    writeServers: (servers: WslServerConfig[]) => {
+      persistedServers = servers
+    },
+    readCommandVersion: async () => "1.16.2",
+    resolveOpencode: async () => {
+      await new Promise<void>((resolve) => {
+        releaseOpencodeResolve = resolve
+      })
+      return "/home/me/.opencode/bin/opencode"
+    },
+  }
+}

+ 70 - 13
packages/desktop/src/main/wsl/servers.ts

@@ -11,7 +11,7 @@ import type {
   WslServersEvent,
   WslServersState,
 } from "../../preload/types"
-import { WSL_SERVERS_KEY } from "../constants"
+import { WSL_SERVERS_KEY } from "../store-keys"
 import { getStore } from "../store"
 import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
 import { clearWslDistroState, wslServerIdToRestart } from "./policy"
@@ -43,18 +43,33 @@ type ControllerLogger = {
   error: (message: string, meta?: unknown) => void
 }
 
+type WslServersControllerOptions = {
+  logger?: ControllerLogger
+  readServers?: () => WslServerConfig[]
+  writeServers?: (servers: WslServerConfig[]) => void
+  resolveOpencode?: typeof resolveWslOpencode
+  readCommandVersion?: typeof readWslCommandVersion
+}
+
 export type WslServersController = ReturnType<typeof createWslServersController>
 
 export function wslServerIdForDistro(distro: string) {
   return `wsl:${distro}`
 }
 
-export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
+export function createWslServersController(
+  appVersion: string,
+  spawnSidecar: SpawnSidecar,
+  options?: WslServersControllerOptions,
+) {
   let state: WslServersState = initialState()
   const listeners = new Set<(event: WslServersEvent) => void>()
   const sidecars = new Map<string, RunningSidecar>()
   const startAttempts = new Map<string, number>()
   let jobAbort: AbortController | undefined
+  const logger = options?.logger
+  const readServers = options?.readServers ?? readPersistedServers
+  const writeServers = options?.writeServers ?? writePersistedServers
 
   const emit = () => {
     for (const listener of listeners) listener({ type: "state", state })
@@ -66,7 +81,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
   }
 
   const persistServers = (servers: WslServerConfig[]) => {
-    getStore().set(WSL_SERVERS_KEY, { servers })
+    writeServers(servers)
   }
 
   const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
@@ -89,7 +104,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
   }
 
   const refreshFromStore = () => {
-    const persisted = readPersistedServers()
+    const persisted = readServers()
     const items: WslServerItem[] = persisted.map((config) => {
       const existing = state.servers.find((item) => item.config.id === config.id)
       return {
@@ -113,10 +128,50 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
     })
   }
 
+  const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
+    const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
+    const version = resolved ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) : null
+    return opencodeCheck(distro, resolved, version, appVersion)
+  }
+
   const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
-    const resolved = await resolveWslOpencode(distro, opts)
-    const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
-    setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
+    setOpencodeCheck(distro, await checkOpencode(distro, opts))
+  }
+
+  const hasServer = (id: string, distro: string) => {
+    return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
+  }
+
+  const refreshOpencodeCheckBackground = (id: string, distro: string) => {
+    void checkOpencode(distro)
+      .then((check) => {
+        if (!hasServer(id, distro)) return
+        setOpencodeCheck(distro, check)
+      })
+      .catch((error) => {
+        const message = error instanceof Error ? error.message : String(error)
+        logger?.error("wsl opencode check failed", { id, distro, message })
+      })
+  }
+
+  const refreshOpencodeChecks = async () => {
+    await Promise.all(
+      state.servers.map((item) =>
+        checkOpencode(item.config.distro)
+          .then((check) => {
+            if (!hasServer(item.config.id, item.config.distro)) return
+            setOpencodeCheck(item.config.distro, check)
+          })
+          .catch((error) => {
+            const message = error instanceof Error ? error.message : String(error)
+            logger?.error("wsl opencode check failed", {
+              id: item.config.id,
+              distro: item.config.distro,
+              message,
+            })
+          }),
+      ),
+    )
   }
 
   const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
@@ -170,10 +225,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
         setRuntime(id, { kind: "failed", message })
         logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
       })
-      void refreshOpencodeCheck(item.config.distro).catch((error) => {
-        const message = error instanceof Error ? error.message : String(error)
-        logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
-      })
+      refreshOpencodeCheckBackground(id, item.config.distro)
       logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error)
@@ -225,6 +277,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
 
     async initialize() {
       refreshFromStore()
+      void refreshOpencodeChecks()
       for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
     },
 
@@ -311,7 +364,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
         id,
         distro,
       }
-      persistServers([...readPersistedServers(), config])
+      persistServers([...readServers(), config])
       setState({
         servers: [...state.servers, { config, runtime: { kind: "starting" } }],
       })
@@ -323,7 +376,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
       const distro = state.servers.find((item) => item.config.id === id)?.config.distro
       invalidateStartAttempt(id)
       await stopServerInternal(id)
-      const remaining = readPersistedServers().filter((item) => item.id !== id)
+      const remaining = readServers().filter((item) => item.id !== id)
       persistServers(remaining)
       setState({
         servers: state.servers.filter((item) => item.config.id !== id),
@@ -371,6 +424,10 @@ function readPersistedServers(): WslServerConfig[] {
   return []
 }
 
+function writePersistedServers(servers: WslServerConfig[]) {
+  getStore().set(WSL_SERVERS_KEY, { servers })
+}
+
 function normalizePersistedServer(value: unknown): WslServerConfig[] {
   if (!value || typeof value !== "object") return []
   const record = value as Record<string, unknown>