feat: expose background service lifecycle (#36895)
This commit is contained in:
parent
ea89a2f619
commit
ece2b16cdf
36 changed files with 2421 additions and 293 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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..." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue