Просмотр исходного кода

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

Luke Parker 3 месяцев назад
Родитель
Сommit
b1a7ee5695

+ 1 - 0
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",

+ 17 - 0
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",
+    )
+  })
+})

+ 11 - 0
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
+}

+ 2 - 1
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<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()}

+ 37 - 0
packages/desktop/src/main/index.test.ts

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

+ 3 - 2
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<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)
 

+ 6 - 0
packages/desktop/src/main/initialization.ts

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

+ 3 - 2
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(() => {
         <Show
           when={
             !defaultServer.loading &&
-            !sidecar.loading &&
+            initializationReady(sidecar) &&
             !windowConfig.loading &&
             !windowCount.loading &&
             !locale.loading

+ 73 - 0
packages/desktop/src/renderer/initialization.test.ts

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

+ 22 - 0
packages/desktop/src/renderer/initialization.ts

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