diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 76c96dcc0..1e7af98cd 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/pages/error-description.test.ts b/packages/app/src/pages/error-description.test.ts new file mode 100644 index 000000000..222cd7a1e --- /dev/null +++ b/packages/app/src/pages/error-description.test.ts @@ -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", + ) + }) +}) diff --git a/packages/app/src/pages/error-description.ts b/packages/app/src/pages/error-description.ts new file mode 100644 index 000000000..86accb6e1 --- /dev/null +++ b/packages/app/src/pages/error-description.ts @@ -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 +} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index f9b65bb94..2a2b7d727 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -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 = (props) => {

{language.t("error.page.title")}

-

{language.t("error.page.description")}

+

{language.t(errorDescriptionKey(props.error))}

{ + const failure = new Error("sidecar startup failed") + const expectFailure = (exit: Exit.Exit) => { + 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() + 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() + 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) + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 75e53e56a..48d22f0db 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -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() + const serverReady = Deferred.makeUnsafe() 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) diff --git a/packages/desktop/src/main/initialization.ts b/packages/desktop/src/main/initialization.ts new file mode 100644 index 000000000..476abac7f --- /dev/null +++ b/packages/desktop/src/main/initialization.ts @@ -0,0 +1,6 @@ +import { Deferred, Effect } from "effect" + +export function forwardInitializationFailure(initialization: Deferred.Deferred) { + return (effect: Effect.Effect) => + effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause))) +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 8ad4a3b09..aaf0ba801 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -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(() => { { + 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) + }) +}) diff --git a/packages/desktop/src/renderer/initialization.ts b/packages/desktop/src/renderer/initialization.ts new file mode 100644 index 000000000..b68eae09c --- /dev/null +++ b/packages/desktop/src/renderer/initialization.ts @@ -0,0 +1,22 @@ +export function initializationData(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(state: (() => A | undefined) & { error: unknown; loading: boolean }) { + if (state.loading) return false + initializationData(state) + return true +}