feat(desktop): surface local server startup failures (#30822)
This commit is contained in:
parent
107180701f
commit
b1a7ee5695
@ -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
packages/app/src/pages/error-description.test.ts
Normal file
17
packages/app/src/pages/error-description.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
11
packages/app/src/pages/error-description.ts
Normal file
11
packages/app/src/pages/error-description.ts
Normal 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
|
||||
}
|
||||
@ -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
packages/desktop/src/main/index.test.ts
Normal file
37
packages/desktop/src/main/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
packages/desktop/src/main/initialization.ts
Normal file
6
packages/desktop/src/main/initialization.ts
Normal 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)))
|
||||
}
|
||||
@ -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
packages/desktop/src/renderer/initialization.test.ts
Normal file
73
packages/desktop/src/renderer/initialization.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
packages/desktop/src/renderer/initialization.ts
Normal file
22
packages/desktop/src/renderer/initialization.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user