fix: surface desktop WSL startup failures

This commit is contained in:
LukeParkerDev
2026-04-16 16:32:06 +10:00
parent 76da54c596
commit 421f3bbd0b
4 changed files with 138 additions and 34 deletions

View File

@@ -1,4 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app"
export { DialogLocalServer } from "./components/dialog-local-server"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"

View File

@@ -55,6 +55,7 @@ const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
void serverReady.promise.catch(() => undefined)
const localServer = createLocalServerController(app.getVersion())
const logger = initLogging()
@@ -164,26 +165,31 @@ async function initialize() {
logger.log("spawning sidecar", { url })
localServer.setRuntime(runtime)
localServer.setStatus({ kind: "running", step: null })
serverReady.resolve({
const startupData = {
url,
username: "opencode",
password,
local: runtime,
})
}
let startupError: Error | null = null
const startup = await (async () => {
try {
if (runtime.mode === "wsl") {
if (!runtime.distro) throw new Error("No WSL distro selected")
return spawnWslLocalServer(runtime.distro, port, password)
return spawnWslLocalServer(runtime.distro, port, password, {
onLine: (line) =>
logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }),
})
}
return spawnLocalServer(hostname, port, password)
} catch (error) {
startupError = asError(error)
localServer.setStatus({
kind: "failed",
step: null,
message: error instanceof Error ? error.message : String(error),
message: startupError.message,
})
logger.error("local server startup failed", error)
logger.error("local server startup failed", startupError)
return undefined
}
})()
@@ -212,15 +218,20 @@ async function initialize() {
])
.then(() => {
localServer.setStatus({ kind: "ready" })
serverReady.resolve(startupData)
})
.catch((error) => {
startupError = asError(error)
localServer.setStatus({
kind: "failed",
step: null,
message: error instanceof Error ? error.message : String(error),
message: startupError.message,
})
logger.error("sidecar health check failed", error)
logger.error("sidecar health check failed", startupError)
serverReady.reject(startupError)
})
} else {
serverReady.reject(startupError ?? new Error("Local server startup failed"))
}
logger.log("loading task finished")
@@ -510,6 +521,10 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void

View File

@@ -3,7 +3,7 @@ import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { wslArgs } from "./wsl"
import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
@@ -48,33 +48,48 @@ export async function spawnLocalServer(hostname: string, port: number, password:
return { listener, health: { wait } }
}
export async function spawnWslLocalServer(distro: string, port: number, password: string) {
const script = [
"set -e",
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
"OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
"OPENCODE_CLIENT=desktop",
`OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`,
`OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'XDG_STATE_HOME="$HOME/.local/state"',
`exec opencode --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
].join(" ")
export async function spawnWslLocalServer(
distro: string,
port: number,
password: string,
opts: { onLine?: (line: WslCommandLine) => void } = {},
) {
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const child = spawn("wsl", wslArgs(["bash", "-lc", script], distro), {
stdio: ["ignore", "pipe", "pipe"],
const script = [
"set -euo pipefail",
"export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
"export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
})
child.stdout.setEncoding("utf8")
child.stderr.setEncoding("utf8")
child.stdin.end(script)
let settled = false
const recentOutput: string[] = []
const emit = (line: WslCommandLine) => {
if (settled || !line.text.trim()) return
recentOutput.push(`[${line.stream}] ${line.text}`)
if (recentOutput.length > 12) recentOutput.shift()
opts.onLine?.(line)
}
forwardLines(child.stdout, "stdout", emit)
forwardLines(child.stderr, "stderr", emit)
const exit = new Promise<never>((_, reject) => {
child.once("error", reject)
child.once("exit", (code, signal) => {
reject(
new Error(
`WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})`,
),
)
reject(new Error(startupFailure(code, signal, recentOutput)))
})
})
@@ -87,7 +102,9 @@ export async function spawnWslLocalServer(distro: string, port: number, password
}
})(),
exit,
])
]).finally(() => {
settled = true
})
return {
listener: {
@@ -119,6 +136,29 @@ function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],
onLine: (line: WslCommandLine) => void,
) {
let pending = ""
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
pending += chunk
const lines = pending.split(/\r?\n/g)
pending = lines.pop() ?? ""
for (const line of lines) onLine({ stream: source, text: line })
})
stream.on("end", () => {
if (pending) onLine({ stream: source, text: pending })
})
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {

View File

@@ -5,6 +5,7 @@ import {
ACCEPTED_FILE_TYPES,
AppBaseProviders,
AppInterface,
DialogLocalServer,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
@@ -23,6 +24,10 @@ import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
const root = document.getElementById("root")
@@ -48,6 +53,34 @@ const listenForDeepLinks = () => {
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
function LocalServerStartupError(props: { message: string }) {
const dialog = useDialog()
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-16-medium text-text-strong">Local Server failed to start</p>
<p class="mt-2 text-12-regular text-text-weak whitespace-pre-wrap break-words">{props.message}</p>
<Button
variant="secondary"
size="large"
class="mt-4"
onClick={() =>
dialog.show(() => (
<Dialog title="Local Server" dismissOutside={false}>
<DialogLocalServer />
</Dialog>
))
}
>
Open Local Server
</Button>
</div>
</div>
)
}
const createPlatform = (): Platform => {
const os = (() => {
const ua = navigator.userAgent
@@ -275,8 +308,19 @@ render(() => {
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [startup] = createResource(async () => {
try {
return {
error: null,
sidecar: await window.api.awaitInitialization(() => undefined),
}
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
sidecar: null,
}
}
})
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
@@ -286,7 +330,7 @@ render(() => {
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
const data = startup.latest?.sidecar
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
@@ -339,12 +383,16 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
<Show when={!defaultServer.loading && !startup.loading && !windowCount.loading && !locale.loading}>
{(_) => {
if (startup.latest?.error) {
return <LocalServerStartupError message={startup.latest.error} />
}
return (
<AppInterface
defaultServer={
defaultServer.latest ?? ServerConnection.Key.make(sidecar.latest?.local.key ?? "local:windows")
defaultServer.latest ??
ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")
}
servers={servers()}
router={MemoryRouter}