diff --git a/packages/app/src/runtime/server/client.test.ts b/packages/app/src/runtime/server/client.test.ts index 343d242e7f4..d768d9d7fd5 100644 --- a/packages/app/src/runtime/server/client.test.ts +++ b/packages/app/src/runtime/server/client.test.ts @@ -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")}`, + }, + ]) +}) diff --git a/packages/app/src/runtime/server/client.tsx b/packages/app/src/runtime/server/client.tsx index c27f8767e72..71ac4db5f72 100644 --- a/packages/app/src/runtime/server/client.tsx +++ b/packages/app/src/runtime/server/client.tsx @@ -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 +} { + 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 } diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 5b347a0cfc7..133fa1db92e 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -210,7 +210,7 @@ export namespace ServerConnection { http: HttpBase } & ( | // Regular desktop server - { variant: "base" } + { variant: "base"; reconnect?: (signal: AbortSignal) => Promise } // WSL server (windows only) | { variant: "wsl" diff --git a/packages/app/src/runtime/server/sync.tsx b/packages/app/src/runtime/server/sync.tsx index 5c1a960576c..59f0704e1d7 100644 --- a/packages/app/src/runtime/server/sync.tsx +++ b/packages/app/src/runtime/server/sync.tsx @@ -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>() - const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api) + const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api) const connected = () => serverSDK.connection.status() === "connected" const [configQuery, pathQuery] = useQueries(() => ({ diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index 6e2300e0ec8..087784bb3b0 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -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)), diff --git a/packages/desktop/src/main/service/background-service-state.test.ts b/packages/desktop/src/main/service/background-service-state.test.ts new file mode 100644 index 00000000000..33b8e69cd61 --- /dev/null +++ b/packages/desktop/src/main/service/background-service-state.test.ts @@ -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) +}) diff --git a/packages/desktop/src/main/service/background-service-state.ts b/packages/desktop/src/main/service/background-service-state.ts new file mode 100644 index 00000000000..892a2abb92b --- /dev/null +++ b/packages/desktop/src/main/service/background-service-state.ts @@ -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 + readonly reconnect: Effect.Effect +}) { + // Every Exit is an Effect, so the latest resolution replays directly for each consumer. + const current = yield* Ref.make>(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)))), + } +}) diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index a7b41cdfd61..8dda7000dc7 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -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 + readonly reconnect: Effect.Effect } export class Service extends Context.Service()("opencode/desktop/BackgroundService") {} @@ -14,22 +16,24 @@ export class Service extends Context.Service()("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() + 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, diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 876ff95267c..1c1a893bb6d 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -22,6 +22,7 @@ export type UpdaterAPI = { export type ElectronAPI = { awaitInitialization(): Promise + reconnectService(): Promise wslServers: WslServersAPI updater: UpdaterAPI consumeInitialDeepLinks(): Promise diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 96173a5ea26..d8881e55718 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -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) => { diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index 339d748cfd8..4fd241e94fe 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -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"))) diff --git a/packages/desktop/src/renderer/startup/initialization.test.ts b/packages/desktop/src/renderer/startup/initialization.test.ts index 7410b9b0cd3..478f5a07827 100644 --- a/packages/desktop/src/renderer/startup/initialization.test.ts +++ b/packages/desktop/src/renderer/startup/initialization.test.ts @@ -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() + 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([]) + }) }) diff --git a/packages/desktop/src/renderer/startup/initialization.ts b/packages/desktop/src/renderer/startup/initialization.ts index c1b064890d9..b4c0ff466fc 100644 --- a/packages/desktop/src/renderer/startup/initialization.ts +++ b/packages/desktop/src/renderer/startup/initialization.ts @@ -1,8 +1,38 @@ +import type { ElectronAPI } from "../api-types" + +type SidecarData = Awaited> + export function initializationData(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 + 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 }) diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index 74dffd08326..f2efad4049f 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -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,