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

This commit is contained in:
Filip 2026-06-08 04:05:54 +02:00 committed by GitHub
parent b1d14acc35
commit 65a3f7f749
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 231 additions and 50 deletions

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 (

View File

@ -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"

View File

@ -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"),

View File

@ -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",

View File

@ -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

View File

@ -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"

View File

@ -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,
})

View File

@ -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))

View File

@ -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)
}

View File

@ -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"
},
}
}

View File

@ -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>