feat(desktop): surface local server startup failures (#30822)

This commit is contained in:
Luke Parker 2026-06-05 08:45:48 +10:00 committed by GitHub
parent 107180701f
commit b1a7ee5695
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 175 additions and 5 deletions

View File

@ -467,6 +467,7 @@ export const dict = {
"error.page.title": "Something went wrong",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",

View File

@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { errorDescriptionKey } from "./error-description"
describe("error description", () => {
test("describes local server startup errors", () => {
expect(errorDescriptionKey(Object.assign(new Error("migration failed"), { localServerStartup: true }))).toBe(
"error.page.description.localServerStartup",
)
})
test("uses the generic description for other errors", () => {
expect(errorDescriptionKey(new Error("unknown"))).toBe("error.page.description")
expect(errorDescriptionKey(Object.assign(new Error("unknown"), { localServerStartup: false }))).toBe(
"error.page.description",
)
})
})

View File

@ -0,0 +1,11 @@
export function errorDescriptionKey(error: unknown) {
if (
typeof error === "object" &&
error !== null &&
"localServerStartup" in error &&
error.localServerStartup === true
) {
return "error.page.description.localServerStartup" as const
}
return "error.page.description" as const
}

View File

@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { errorDescriptionKey } from "./error-description"
export type InitError = {
name: string
@ -289,7 +290,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
<p class="text-sm text-text-weak">{language.t(errorDescriptionKey(props.error))}</p>
</div>
<TextField
value={formattedError()}

View File

@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { forwardInitializationFailure } from "./initialization"
describe("desktop initialization", () => {
const failure = new Error("sidecar startup failed")
const expectFailure = (exit: Exit.Exit<unknown, unknown>) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(Cause.squash(exit.cause)).toBe(failure)
}
test("forwards loading task failures before renderer initialization", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Deferred.await(initialization).pipe(Effect.exit)
}),
)
expectFailure(exit)
})
test("forwards loading task failures while renderer initialization waits", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild)
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Fiber.join(waiting)
}),
)
expectFailure(exit)
})
})

View File

@ -14,6 +14,7 @@ import type { ServerReadyData, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
@ -207,7 +208,7 @@ const main = Effect.gen(function* () {
})
}
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
registerIpcHandlers({
killSidecar: () => killSidecar(),
@ -314,7 +315,7 @@ const main = Effect.gen(function* () {
)
logger.log("loading task finished")
}).pipe(Effect.forkChild)
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
yield* Fiber.await(loadingTask)

View File

@ -0,0 +1,6 @@
import { Deferred, Effect } from "effect"
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause)))
}

View File

@ -21,6 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
@ -329,7 +330,7 @@ render(() => {
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
const data = initializationData(sidecar)
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
@ -383,7 +384,7 @@ render(() => {
<Show
when={
!defaultServer.loading &&
!sidecar.loading &&
initializationReady(sidecar) &&
!windowConfig.loading &&
!windowCount.loading &&
!locale.loading

View File

@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
test("throws the original initialization error before rendering server providers", () => {
const error = new Error("sidecar startup failed")
try {
initializationData(Object.assign(() => undefined, { error }))
throw new Error("expected initialization to fail")
} catch (failure) {
expect(failure).toBe(error)
expect((failure as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
}
})
test("removes Electron's remote invocation wrapper from startup errors", () => {
const error = new Error(
"Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections",
)
try {
initializationData(Object.assign(() => undefined, { error }))
throw new Error("expected initialization to fail")
} catch (failure) {
expect(failure).toBe(error)
expect((failure as Error).message).toBe("Cannot migrate session_message projections")
}
})
test("returns initialized sidecar data", () => {
const sidecar = { url: "http://127.0.0.1:1234", username: "opencode", password: "secret" }
expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar)
})
test("does not discard falsy initialization errors", () => {
let caught: unknown
try {
initializationData(Object.assign(() => undefined, { error: "" }))
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
if (!(caught instanceof Error)) return
expect(caught.message).toBe("")
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
})
test("checks initialization errors before rendering server providers", () => {
const error = new Error("sidecar startup failed")
expect(() => initializationReady(Object.assign(() => undefined, { error, loading: false }))).toThrow(error)
})
test("waits for pending initialization without reading it", () => {
let reads = 0
expect(
initializationReady(
Object.assign(
() => {
reads++
return undefined
},
{ error: undefined, loading: true },
),
),
).toBe(false)
expect(reads).toBe(0)
})
})

View File

@ -0,0 +1,22 @@
export function initializationData<A>(state: (() => A | undefined) & { error: unknown }) {
if (state.error !== undefined) throw markLocalServerStartup(state.error)
return state()
}
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
const prefix = "Error invoking remote method 'await-initialization': Error: "
if (failure.message.startsWith(prefix)) {
const previous = failure.message
failure.message = failure.message.slice(prefix.length)
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
}
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
export function initializationReady<A>(state: (() => A | undefined) & { error: unknown; loading: boolean }) {
if (state.loading) return false
initializationData(state)
return true
}