feat(client): self-contained local service discovery and lifecycle
This commit is contained in:
parent
b6c3a8ea7b
commit
1de3c6e4a6
27 changed files with 535 additions and 480 deletions
|
|
@ -138,7 +138,7 @@ const appBindingCommands = [
|
|||
export type TuiInput = {
|
||||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
|
|
@ -301,7 +301,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider client={input.client} api={input.api} reload={input.reload}>
|
||||
<SDKProvider client={input.client} api={input.api} discover={input.discover}>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
|
|
@ -374,7 +374,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const reload = sdk.reload
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
|
|
@ -801,33 +800,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
...(reload
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
title: "Reload server",
|
||||
slashName: "reload",
|
||||
slashAliases: ["restart"],
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Reloading server...",
|
||||
duration: 30000,
|
||||
})
|
||||
await reload()
|
||||
.then(() =>
|
||||
toast.show({
|
||||
variant: "success",
|
||||
message: "Server reloaded",
|
||||
}),
|
||||
)
|
||||
.catch(toast.error)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "theme.switch",
|
||||
title: "Switch theme",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
init: (props: {
|
||||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
}) => {
|
||||
const abort = new AbortController()
|
||||
let client = props.client
|
||||
|
|
@ -32,12 +32,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
connectedOnce: false,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
let pending: Promise<void> | undefined
|
||||
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
const current = client
|
||||
let connected!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
connected = resolve
|
||||
|
|
@ -54,7 +52,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
)
|
||||
controller.signal.addEventListener("abort", cancel, { once: true })
|
||||
const error = await (async () => {
|
||||
const response = await current.v2.event.subscribe({
|
||||
const response = await client.v2.event.subscribe({
|
||||
signal: connection.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
throwOnError: true,
|
||||
|
|
@ -86,6 +84,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
attempt += 1
|
||||
// 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.discover) {
|
||||
const next = await props.discover().catch(() => undefined)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
client = next.client
|
||||
api = next.api
|
||||
}
|
||||
}
|
||||
setConnection({
|
||||
status: "connecting",
|
||||
attempt,
|
||||
|
|
@ -97,23 +106,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
return ready
|
||||
}
|
||||
|
||||
const reload = props.reload
|
||||
? () => {
|
||||
if (pending) return pending
|
||||
pending = Promise.resolve()
|
||||
.then(props.reload)
|
||||
.then(async (next) => {
|
||||
client = next.client
|
||||
api = next.api
|
||||
if (!abort.signal.aborted) await start()
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
: undefined
|
||||
|
||||
onMount(() => void start())
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
|
|
@ -146,7 +138,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
return connection.connectedOnce
|
||||
},
|
||||
},
|
||||
reload,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function update(version: string): V2Event {
|
|||
}
|
||||
}
|
||||
|
||||
async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
|
||||
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const seen: V2Event[] = []
|
||||
|
|
@ -61,7 +61,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reload={reload}>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={async (ctx) => {
|
||||
|
|
@ -79,7 +79,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC
|
|||
))
|
||||
|
||||
await ready
|
||||
return { app, emit: events.emit, project, sdk, seen, workspaces }
|
||||
return { app, events, emit: events.emit, project, sdk, seen, workspaces }
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
|
|
@ -148,24 +148,25 @@ describe("useEvent", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("reloads the host and reconnects the event stream", async () => {
|
||||
test("rediscovers the server after the event stream drops", async () => {
|
||||
let calls = 0
|
||||
const events = createEventStream()
|
||||
const replacementCalls = createFetch(undefined, events)
|
||||
const replacementEvents = createEventStream()
|
||||
const replacementCalls = createFetch(undefined, replacementEvents)
|
||||
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
||||
const { app, sdk, seen } = await mount(async () => {
|
||||
const { app, events, sdk, seen } = await mount(async () => {
|
||||
calls += 1
|
||||
return replacement
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
await sdk.reload?.()
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
|
||||
// Discovery only runs when the stream is down, never while connected.
|
||||
expect(calls).toBe(0)
|
||||
events.disconnect()
|
||||
await wait(() => sdk.connection.status() === "connected" && calls > 0)
|
||||
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(sdk.client).toBe(replacement.client)
|
||||
expect(sdk.api).toBe(replacement.api)
|
||||
} finally {
|
||||
|
|
@ -173,27 +174,24 @@ describe("useEvent", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("keeps the current event stream alive while the host reload is pending", async () => {
|
||||
let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void
|
||||
const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => {
|
||||
complete = resolve
|
||||
test("keeps the current client when discovery fails", async () => {
|
||||
let calls = 0
|
||||
const { app, events, sdk, seen } = await mount(async () => {
|
||||
calls += 1
|
||||
throw new Error("no server")
|
||||
})
|
||||
const replacementEvents = createEventStream()
|
||||
const replacementCalls = createFetch(undefined, replacementEvents)
|
||||
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
||||
const { app, emit, sdk, seen } = await mount(() => pending)
|
||||
|
||||
try {
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
const reload = sdk.reload?.()
|
||||
emit(event(vcs("during-reload"), { directory: "/tmp/reload" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload"))
|
||||
const original = sdk.client
|
||||
events.disconnect()
|
||||
// Discovery rejects; the loop retries against the last known transport,
|
||||
// which succeeds once the fixture accepts the reconnect.
|
||||
await wait(() => calls > 0 && sdk.connection.status() === "connected")
|
||||
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "recovered"))
|
||||
|
||||
expect(sdk.connection.status()).toBe("connected")
|
||||
complete(replacement)
|
||||
await reload
|
||||
expect(sdk.client).toBe(replacement.client)
|
||||
expect(sdk.api).toBe(replacement.api)
|
||||
expect(sdk.client).toBe(original)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue