fix(desktop): reconnect to elected service (#44369)

This commit is contained in:
Luke Parker 2026-08-24 08:27:04 +10:00 committed by GitHub
parent 481125f617
commit d80b0a1e7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 213 additions and 31 deletions

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./client"
import { createOpenCodeEventSource, createServerTransport } from "./client"
const permission = {
id: "evt_permission",
@ -84,3 +84,39 @@ describe("server event stream", () => {
second.dispose()
})
})
test("rotates HTTP and PTY clients together", async () => {
const requests: Array<{ url: string; authorization: string | null }> = []
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
fetch,
})
const initialPty = transport.pty
await transport.api.health.get()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
username: "opencode",
password: "second",
})
await transport.api.health.get()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/health",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/health",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
})

View file

@ -72,11 +72,12 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
const events = createOpenCodeEventSource()
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
const connection = createClientConnection(api, {
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
@ -85,7 +86,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
log: {
info(message, data) {
if (message !== "event stream disconnected") return
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
},
},
})
@ -93,14 +94,48 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return {
server,
scope,
url: server.http.url,
api,
pty,
get url() {
return transport.url
},
get api() {
return transport.api
},
get pty() {
return transport.pty
},
connection,
event: events.event,
}
}
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
update(http: ServerConnection.HttpBase): ServerApi
readonly url: string
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
return {
update(http: ServerConnection.HttpBase) {
state.current = build(http)
return state.current.api
},
get url() {
return state.current.http.url
},
get api() {
return state.current.api
},
get pty() {
return state.current.pty
},
}
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}

View file

@ -210,7 +210,7 @@ export namespace ServerConnection {
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
// WSL server (windows only)
| {
variant: "wsl"

View file

@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
queryFn: async () => [],
})
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
path: () => loadPathQuery(scope, null, serverAPI.location),
path: () => loadPathQuery(scope, null, serverAPI().location),
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
}
}
@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!owner) throw new Error("ServerSync must be created within owner")
const booting = new Map<string, Promise<void>>()
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const [configQuery, pathQuery] = useQueries(() => ({

View file

@ -30,6 +30,7 @@ export const appHandlers = AppRpcs.toLayer(
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection,
AppReconnectService: () => background.reconnect,
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),

View file

@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { BackgroundServiceState } from "./background-service-state"
test("new consumers receive the latest reconnected service", async () => {
const initial = { url: "http://127.0.0.1:4100", username: "opencode", password: "first" }
const replacement = { url: "http://127.0.0.1:4200", username: "opencode", password: "second" }
const service = await Effect.runPromise(
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
)
expect(await Effect.runPromise(service.connection)).toEqual(initial)
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
})

View file

@ -0,0 +1,16 @@
export * as BackgroundServiceState from "./background-service-state"
import { Effect, Exit, Ref } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
readonly initial: Effect.Effect<ServerReadyData, unknown>
readonly reconnect: Effect.Effect<ServerReadyData>
}) {
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
return {
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
}
})

View file

@ -1,12 +1,14 @@
import { app } from "electron"
import { Context, Effect, Exit, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
export * as BackgroundService from "./background-service"
export interface Interface {
readonly connection: Effect.Effect<ServerReadyData>
readonly reconnect: Effect.Effect<ServerReadyData>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
@ -14,22 +16,24 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const result = yield* start().pipe(Effect.exit)
return Service.of({
connection: Exit.isSuccess(result)
? Effect.succeed(result.value)
: Effect.failCause(result.cause).pipe(Effect.orDie),
})
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path | DesktopCli.Service>()
return Service.of(
yield* BackgroundServiceState.make({
initial: connect("initial").pipe(Effect.provide(context)),
reconnect: connect("reconnect").pipe(Effect.provide(context), Effect.orDie),
}),
)
}),
)
const start = Effect.fn("BackgroundService.start")(function* () {
const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
yield* Effect.logInfo("starting v2 background service")
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const cli = yield* desktopCli.resolve
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode-ai/client/service"))
const service = yield* Effect.tryPromise(() =>
@ -38,7 +42,7 @@ const start = Effect.fn("BackgroundService.start")(function* () {
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
@ -49,10 +53,10 @@ const start = Effect.fn("BackgroundService.start")(function* () {
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
version,
...endpoint(url.origin),
})
if (isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
return {
url: url.origin,
username: service.auth.username,

View file

@ -22,6 +22,7 @@ export type UpdaterAPI = {
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>

View file

@ -24,6 +24,7 @@ const updaterHandler = (state: UpdaterState) => {
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {

View file

@ -24,7 +24,7 @@ import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { initializationData } from "./startup/initialization"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
import { LoadingSplash } from "./startup/splash"
import { getLastActiveUrl } from "./window/route-storage"
@ -75,7 +75,7 @@ function DesktopWindow(props: {
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
@ -97,11 +97,8 @@ function DesktopWindow(props: {
displayName: language.t("desktop.server.local"),
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
http: sidecarHttp(data),
reconnect: createSidecarResolver({ api: props.api, current: sidecar, update: setSidecar }),
})
}
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { initializationData } from "./initialization"
import { createSidecarResolver, initializationData } from "./initialization"
describe("desktop renderer initialization", () => {
test("throws the original initialization error before rendering server providers", () => {
@ -45,4 +45,48 @@ describe("desktop renderer initialization", () => {
expect(caught.message).toBe("")
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
})
test("refreshes the managed sidecar endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => sidecar },
current: () => undefined,
update: (next) => updates.push(next),
})
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
expect(updates).toEqual([sidecar])
})
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "same" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => ({ ...sidecar }) },
current: () => sidecar,
update: (next) => updates.push(next),
})
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
expect(updates).toEqual([])
})
test("does not publish a sidecar resolved after cancellation", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
const pending = Promise.withResolvers<typeof sidecar>()
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: () => pending.promise },
current: () => undefined,
update: (next) => updates.push(next),
})
const abort = new AbortController()
const result = resolve(abort.signal)
abort.abort()
pending.resolve(sidecar)
await expect(result).rejects.toBe(abort.signal.reason)
expect(updates).toEqual([])
})
})

View file

@ -1,8 +1,38 @@
import type { ElectronAPI } from "../api-types"
type SidecarData = Awaited<ReturnType<ElectronAPI["awaitInitialization"]>>
export function initializationData<A>(state: (() => A | undefined) & { error: unknown }) {
if (state.error !== undefined) throw markLocalServerStartup(state.error)
return state()
}
export function sidecarHttp(data: SidecarData) {
return {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
}
export function createSidecarResolver(input: {
api: Pick<ElectronAPI, "reconnectService">
current: () => SidecarData | undefined
update: (data: SidecarData) => void
}) {
return async (signal: AbortSignal) => {
if (signal.aborted) throw signal.reason
const next = await input.api.reconnectService()
if (signal.aborted) throw signal.reason
if (!sameSidecar(input.current(), next)) input.update(next)
return sidecarHttp(next)
}
}
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
return current?.url === next.url && current.username === next.username && current.password === next.password
}
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
Object.defineProperty(failure, "localServerStartup", { value: true })

View file

@ -8,6 +8,7 @@ const ServerReadyData = Schema.Struct({
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
success: Schema.Array(Schema.String),
})
@ -56,6 +57,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppReconnectService,
AppConsumeInitialDeepLinks,
AppGetDefaultServerUrl,
AppSetDefaultServerUrl,