feat: expose background service lifecycle (#36895)

This commit is contained in:
Kit Langton 2026-07-14 16:38:22 -04:00 committed by GitHub
commit ece2b16cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2421 additions and 293 deletions

View file

@ -139,7 +139,7 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Service.Endpoint
reconnect?: (attempt: number) => Promise<Service.Endpoint>
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
reload?: () => Promise<void>
}
args: Args
@ -186,8 +186,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const reconnectEndpoint = input.server.reconnect
const reconnect = reconnectEndpoint
? async (attempt: number) => {
const endpoint = await reconnectEndpoint(attempt)
? async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
const endpoint = await reconnectEndpoint(onStatus, signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return {
api: OpenCode.make(next),
@ -1121,7 +1121,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
<Reconnecting status={client.connection.service()} />
</Show>
</box>
)

View file

@ -1,9 +1,11 @@
import type { Service } from "@opencode-ai/client/effect"
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { attempt: number; error?: string }) {
export function Reconnecting(props: { status?: Service.Status }) {
const theme = useTheme().theme
const copy = () => reconnectingCopy(props.status)
return (
<box
@ -17,16 +19,47 @@ export function Reconnecting(props: { attempt: number; error?: string }) {
alignItems="center"
justifyContent="center"
>
<box width={54} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<text fg={theme.text}>Connection lost</text>
<Spinner color={theme.textMuted}>Reconnecting to server...</Spinner>
<text fg={theme.textMuted}>Attempt {props.attempt}</text>
<Show when={props.error}>
<text fg={theme.error} wrapMode="word">
{props.error}
</text>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</Spinner>}>
<text fg={theme.error}>{copy().message}</text>
<Show when={copy().detail}>
{(detail) => (
<text fg={theme.textMuted} wrapMode="word">
{detail()}
</text>
)}
</Show>
<Show when={copy().action}>
{(action) => (
<text fg={theme.text} wrapMode="word">
{action()}
</text>
)}
</Show>
</Show>
</box>
</box>
)
}
export function reconnectingCopy(status?: Service.Status) {
if (status?.type === "starting")
return {
loading: true,
message: status.version ? `Starting OpenCode ${status.version}...` : "Starting background service...",
}
if (status?.type === "stopping")
return {
loading: true,
message: status.targetVersion ? `Updating to ${status.targetVersion}...` : "Restarting background service...",
}
if (status?.type === "failed")
return { loading: false, message: "Background service failed", detail: status.message, action: status.action }
if (status?.type === "unresponsive")
return {
loading: false,
message: "Background service is not responding",
action: "Run `opencode service restart` to recover it.",
}
return { loading: true, message: "Waiting for background service..." }
}

View file

@ -1,7 +1,9 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
@ -24,7 +26,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
name: "Client",
init: (props: {
api: OpenCodeClient
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
// Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void>
}) => {
@ -41,6 +43,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
status: "connecting",
attempt: 0,
})
const [service, setService] = createSignal<Service.Status>()
let stream: AbortController | undefined
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
@ -51,43 +54,37 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
function start() {
stream?.abort()
const controller = new AbortController()
let connected!: () => void
const ready = new Promise<void>((resolve) => {
connected = resolve
})
stream = controller
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
const connection = new AbortController()
const cancel = () => connection.abort(controller.signal.reason)
const timeout = setTimeout(
() => connection.abort(new Error("Timed out connecting to server")),
connectTimeout,
)
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]()
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (first.done)
return connection.signal.reason instanceof Error
? connection.signal.reason
return request.signal.reason instanceof Error
? request.signal.reason
: new Error("Event stream disconnected")
if (first.value.type !== "server.connected")
return new Error("Event stream did not start with server.connected")
clearTimeout(timeout)
record("connected", attempt)
attempt = 0
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
connected()
setService(undefined)
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (event.done) return new Error("Event stream disconnected")
if ("durable" in event.value)
log.debug("event", {
@ -97,42 +94,47 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
})
events.emit(event.value.type, event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
})
if (abort.signal.aborted || controller.signal.aborted) return
if (connectedAt !== undefined && Date.now() - connectedAt >= 1_000) attempt = 0
attempt += 1
const message = error instanceof Error ? error.message : String(error)
const message = errorMessage(error)
record("disconnected", attempt, message)
log.info("event stream disconnected", {
attempt,
error: message,
})
setConnection({ status: "reconnecting", attempt, error: message })
// Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address.
if (props.reconnect) {
const next = await props.reconnect(attempt).catch(() => undefined)
const next = await props.reconnect(setService, controller.signal).catch((error) => {
if (!controller.signal.aborted)
log.info("server resolution failed", {
attempt,
error: errorMessage(error),
})
})
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next.api
if (attempt === 1) continue
}
}
setConnection({
status: "reconnecting",
attempt,
error: message,
})
await wait(1_000, controller.signal)
}
})()
return ready
}
onMount(() => void start())
onMount(start)
onCleanup(() => {
abort.abort()
stream?.abort()
@ -157,6 +159,9 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
error() {
return connection.error
},
service() {
return service()
},
internal: {
history() {
return history.slice()

View file

@ -0,0 +1,31 @@
import { expect, test } from "bun:test"
import { reconnectingCopy } from "../../../src/component/reconnecting"
test("describes service status without transport diagnostics", () => {
expect(reconnectingCopy({ type: "starting", version: "2.0.0" })).toEqual({
loading: true,
message: "Starting OpenCode 2.0.0...",
})
expect(reconnectingCopy({ type: "stopping", targetVersion: "2.0.0" })).toEqual({
loading: true,
message: "Updating to 2.0.0...",
})
expect(
reconnectingCopy({
type: "failed",
message: "Could not open the database.",
action: "Check the service logs.",
}),
).toEqual({
loading: false,
message: "Background service failed",
detail: "Could not open the database.",
action: "Check the service logs.",
})
expect(reconnectingCopy({ type: "unresponsive" })).toEqual({
loading: false,
message: "Background service is not responding",
action: "Run `opencode service restart` to recover it.",
})
expect(JSON.stringify(reconnectingCopy())).not.toMatch(/Attempt|ECONNREFUSED|Event stream disconnected/)
})

View file

@ -1,6 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
@ -53,7 +54,7 @@ function update(version: string): OpenCodeEvent {
}
async function mount(
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>,
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
const events = createEventStream()
@ -194,8 +195,8 @@ describe("useEvent", () => {
const replacementEvents = createEventStream()
const replacementCalls = createFetch(undefined, replacementEvents)
const replacement = { api: createApi(replacementCalls.fetch) }
const { app, events, client, seen } = await mount(async (attempt) => {
attempts.push(attempt)
const { app, events, client, seen } = await mount(async () => {
attempts.push(attempts.length + 1)
return replacement
})
@ -246,4 +247,106 @@ describe("useEvent", () => {
app.renderer.destroy()
}
})
test("backs off when a resolved event stream keeps failing", async () => {
let calls = 0
const encoder = new TextEncoder()
const replacementCalls = createFetch((url) => {
if (url.pathname !== "/api/event") return undefined
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n'),
)
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
})
const replacement = {
api: createApi(replacementCalls.fetch),
}
const { app, events, client } = await mount(async () => {
calls += 1
return replacement
})
try {
await wait(() => client.connection.status() === "connected")
events.disconnect()
await Promise.race([
wait(() => calls === 2),
Bun.sleep(500).then(() => {
throw new Error("resolved event stream did not retry immediately")
}),
])
await Bun.sleep(200)
expect(calls).toBe(2)
} finally {
app.renderer.destroy()
}
})
test("reports service status while endpoint resolution is pending", async () => {
const replacementEvents = createEventStream()
const replacement = { api: createApi(createFetch(undefined, replacementEvents).fetch) }
let report!: (status: Service.Status) => void
let resolve!: (value: typeof replacement) => void
const endpoint = new Promise<typeof replacement>((done) => {
resolve = done
})
const { app, events, client } = await mount(async (onStatus) => {
report = onStatus
onStatus({ type: "starting", version: "2.0.0" })
return endpoint
})
try {
await wait(() => client.connection.status() === "connected")
events.disconnect()
await wait(
() => client.connection.status() === "reconnecting" && client.connection.service()?.type === "starting",
)
expect(client.connection.service()).toEqual({ type: "starting", version: "2.0.0" })
report({ type: "failed", message: "Could not open the database.", action: "Check the service logs." })
await wait(() => client.connection.service()?.type === "failed")
expect(client.connection.service()).toEqual({
type: "failed",
message: "Could not open the database.",
action: "Check the service logs.",
})
resolve(replacement)
await wait(() => client.connection.status() === "connected")
expect(client.connection.service()).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("cancels pending endpoint resolution on cleanup", async () => {
let aborted = false
const { app, events, client } = await mount(
(_onStatus, signal) =>
new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
aborted = true
reject(signal.reason)
},
{ once: true },
)
}),
)
await wait(() => client.connection.status() === "connected")
events.disconnect()
await wait(() => client.connection.status() === "reconnecting")
app.renderer.destroy()
await wait(() => aborted)
})
})