mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(tui): recover managed connections during bootstrap
This commit is contained in:
@@ -103,6 +103,7 @@ import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
import { bootstrap } from "./bootstrap"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -207,13 +208,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
|
||||
terminalSuspend: process.platform !== "win32",
|
||||
})
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
|
||||
)
|
||||
const directory = location.directory
|
||||
const connection = yield* bootstrap(input.server, process.cwd())
|
||||
const directory = connection.location.directory
|
||||
const pluginDirectories = yield* Effect.promise(() => localPluginDirectories(process.cwd(), global.config))
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const managed = input.server.service
|
||||
@@ -376,7 +372,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
|
||||
<ClientProvider
|
||||
api={connection.api}
|
||||
url={connection.endpoint.url}
|
||||
service={service}
|
||||
>
|
||||
<PermissionProvider>
|
||||
<DataProvider directory={directory}>
|
||||
<LocationProvider>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ClientError, OpenCode } from "@opencode/client"
|
||||
import { Service, type Endpoint } from "@opencode/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const bootstrap = Effect.fn("Tui.bootstrap")(function* (
|
||||
server: {
|
||||
endpoint: Endpoint
|
||||
service?: { reconnect: (signal: AbortSignal) => Promise<Endpoint> }
|
||||
},
|
||||
directory: string,
|
||||
) {
|
||||
return yield* connect(server.endpoint, directory).pipe(
|
||||
Effect.catch((error) => {
|
||||
const service = server.service
|
||||
if (!service || !(error instanceof ClientError) || error.reason !== "Transport") return Effect.fail(error)
|
||||
// Recovery is outside connect: a second transport failure cannot start another retry.
|
||||
return Effect.tryPromise({ try: (signal) => service.reconnect(signal), catch: (cause) => cause }).pipe(
|
||||
Effect.flatMap((endpoint) => connect(endpoint, directory)),
|
||||
)
|
||||
}),
|
||||
Effect.timeoutOrElse({
|
||||
duration: "2 minutes",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new Error("Timed out connecting to the server during TUI startup. Check service status and try again."),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const connect = Effect.fnUntraced(function* (endpoint: Endpoint, directory: string) {
|
||||
const api = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = yield* Effect.tryPromise({
|
||||
try: (signal) => api.file.list({ location: { directory } }, { signal }),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.map((response) => response.location),
|
||||
Effect.catch((error) => {
|
||||
// A lost connection needs a fresh endpoint, not another request to the same URL.
|
||||
if (error instanceof ClientError && error.reason === "Transport") return Effect.fail(error)
|
||||
return Effect.tryPromise({ try: (signal) => api.location.get(undefined, { signal }), catch: (cause) => cause })
|
||||
}),
|
||||
)
|
||||
return { api, endpoint, location }
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Service } from "@opencode/client/service"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import type { run } from "../src/app"
|
||||
import { createEventStream, createFetch } from "./fixture/tui-client"
|
||||
|
||||
test("real TUI bootstrap uses managed rediscovery after its resolved listener closes", async () => {
|
||||
const source = process.env.OPENCODE_BOOTSTRAP_TREE ?? path.resolve(import.meta.dir, "../../..")
|
||||
const app: { run: typeof run } = await import(path.join(source, "packages/tui/src/app.tsx"))
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "opencode-bootstrap-"))
|
||||
const file = path.join(directory, "service.json")
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false })
|
||||
setup.renderer.start()
|
||||
const calls = createFetch(undefined, createEventStream())
|
||||
using original = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => Response.json({ pid: process.pid, version: "test" }),
|
||||
})
|
||||
const requests: string[] = []
|
||||
using successor = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (new URL(request.url).pathname === "/api/info") return Response.json({ pid: process.pid, version: "test" })
|
||||
return calls.fetch(request)
|
||||
},
|
||||
})
|
||||
let reconnects = 0
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({ id: "original", pid: process.pid, url: original.url.toString(), version: "test" }),
|
||||
)
|
||||
const endpoint = await Service.ensure({ file, command: [] })
|
||||
expect(endpoint.url).toBe(original.url.toString())
|
||||
await original.stop(true)
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({ id: "successor", pid: process.pid, url: successor.url.toString(), version: "test" }),
|
||||
)
|
||||
const task = Effect.runPromise(
|
||||
app
|
||||
.run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: {
|
||||
endpoint,
|
||||
service: {
|
||||
reconnect: async () => {
|
||||
reconnects++
|
||||
return Service.ensure({ file, command: [] })
|
||||
},
|
||||
restart: async () => {
|
||||
throw new Error("Unexpected restart")
|
||||
},
|
||||
},
|
||||
},
|
||||
config: { get: async () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
})
|
||||
.pipe(
|
||||
Effect.provide(Global.layerWith({ state: directory, config: directory })),
|
||||
Effect.provide(AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
const failed = task.then(() => {
|
||||
throw new Error("TUI exited before ready")
|
||||
})
|
||||
await Promise.race([ready.promise, failed])
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
expect(reconnects).toBe(1)
|
||||
expect(requests).toContain("/api/info")
|
||||
expect(requests).toContain("/api/fs/list")
|
||||
expect(requests).toContain("/api/event")
|
||||
expect(setup.renderer.isDestroyed).toBe(false)
|
||||
// Consume the expected normal completion as well as the early-failure branch.
|
||||
void failed.catch(() => {})
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
controller.abort()
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { ClientError } from "@opencode/client"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { bootstrap } from "../src/bootstrap"
|
||||
|
||||
test("bootstrap recovers a different managed endpoint and its credentials", async () => {
|
||||
using old = Bun.serve({ port: 0, fetch: () => Response.json({}) })
|
||||
const requests: string[] = []
|
||||
using next = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push(new URL(request.url).pathname)
|
||||
expect(request.headers.get("authorization")).toBe("Basic " + btoa("opencode:successor"))
|
||||
return Response.json({ location: { directory: "/successor" } })
|
||||
},
|
||||
})
|
||||
const endpoint = {
|
||||
url: next.url.toString(),
|
||||
auth: { type: "basic" as const, username: "opencode", password: "successor" },
|
||||
}
|
||||
await old.stop(true)
|
||||
let reconnects = 0
|
||||
const result = await Effect.runPromise(
|
||||
bootstrap(
|
||||
{
|
||||
endpoint: { url: old.url.toString() },
|
||||
service: {
|
||||
reconnect: async (signal) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
reconnects++
|
||||
return endpoint
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
),
|
||||
)
|
||||
expect(reconnects).toBe(1)
|
||||
expect(result.endpoint).toEqual(endpoint)
|
||||
expect(result.location.directory).toBe("/successor")
|
||||
// The client returned to the TUI must use the new connection, too.
|
||||
await result.api.file.list()
|
||||
expect(requests).toEqual(["/api/fs/list", "/api/fs/list"])
|
||||
})
|
||||
|
||||
test("bootstrap keeps the healthy fast path and semantic location fallback", async () => {
|
||||
for (const fallback of [false, true]) {
|
||||
const requests: string[] = []
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/location") return Response.json({ directory: "/fallback" })
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/requested")
|
||||
return fallback ? new Response(null, { status: 403 }) : Response.json({ location: { directory: "/requested" } })
|
||||
},
|
||||
})
|
||||
let reconnects = 0
|
||||
const result = await Effect.runPromise(
|
||||
bootstrap(
|
||||
{
|
||||
endpoint: { url: server.url.toString() },
|
||||
service: {
|
||||
reconnect: async () => {
|
||||
reconnects++
|
||||
throw new Error("Unexpected reconnect")
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
),
|
||||
)
|
||||
expect(reconnects).toBe(0)
|
||||
expect(result.location.directory).toBe(fallback ? "/fallback" : "/requested")
|
||||
expect(requests).toEqual(fallback ? ["/api/fs/list", "/api/location"] : ["/api/fs/list"])
|
||||
}
|
||||
})
|
||||
|
||||
test("bootstrap does not reconnect on HTTP or malformed response failures", async () => {
|
||||
for (const mode of ["http", "malformed"]) {
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
mode === "http"
|
||||
? new Response(null, { status: 403 })
|
||||
: new Response("not json", { headers: { "content-type": "application/json" } }),
|
||||
})
|
||||
let reconnects = 0
|
||||
const error = await Effect.runPromise(
|
||||
bootstrap(
|
||||
{
|
||||
endpoint: { url: server.url.toString() },
|
||||
service: {
|
||||
reconnect: async () => {
|
||||
reconnects++
|
||||
throw new Error("Unexpected reconnect")
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
).pipe(Effect.flip),
|
||||
)
|
||||
expect(error).toBeInstanceOf(ClientError)
|
||||
expect(reconnects).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("bootstrap retries only once and preserves a reconnect failure", async () => {
|
||||
using old = Bun.serve({ port: 0, fetch: () => Response.json({}) })
|
||||
const endpoint = { url: old.url.toString() }
|
||||
await old.stop(true)
|
||||
for (const fails of [false, true]) {
|
||||
let reconnects = 0
|
||||
const failure = new Error("Background service failed to start")
|
||||
const error = await Effect.runPromise(
|
||||
bootstrap(
|
||||
{
|
||||
endpoint,
|
||||
service: {
|
||||
reconnect: async () => {
|
||||
reconnects++
|
||||
if (fails) throw failure
|
||||
return endpoint
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
).pipe(Effect.flip),
|
||||
)
|
||||
expect(reconnects).toBe(1)
|
||||
if (fails) expect(error).toBe(failure)
|
||||
if (!fails) expect(error).toBeInstanceOf(ClientError)
|
||||
}
|
||||
})
|
||||
|
||||
test("bootstrap leaves explicit and standalone connection failures explicit", async () => {
|
||||
using old = Bun.serve({ port: 0, fetch: () => Response.json({}) })
|
||||
const endpoint = { url: old.url.toString() }
|
||||
await old.stop(true)
|
||||
const error = await Effect.runPromise(bootstrap({ endpoint }, "/requested").pipe(Effect.flip))
|
||||
expect(error).toBeInstanceOf(ClientError)
|
||||
})
|
||||
|
||||
test("bootstrap cancellation aborts the managed reconnect", async () => {
|
||||
using old = Bun.serve({ port: 0, fetch: () => Response.json({}) })
|
||||
const endpoint = { url: old.url.toString() }
|
||||
await old.stop(true)
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const controller = new AbortController()
|
||||
const pending = Effect.runPromise(
|
||||
bootstrap(
|
||||
{
|
||||
endpoint,
|
||||
service: {
|
||||
reconnect: (signal) => {
|
||||
entered.resolve(signal)
|
||||
return new Promise(() => {})
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
).catch((error: unknown) => error)
|
||||
const signal = await entered.promise
|
||||
controller.abort()
|
||||
await pending
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("bootstrap deadline aborts a stuck managed reconnect", async () => {
|
||||
using old = Bun.serve({ port: 0, fetch: () => Response.json({}) })
|
||||
const endpoint = { url: old.url.toString() }
|
||||
await old.stop(true)
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* bootstrap(
|
||||
{
|
||||
endpoint,
|
||||
service: {
|
||||
reconnect: (signal) => {
|
||||
entered.resolve(signal)
|
||||
return new Promise(() => {})
|
||||
},
|
||||
},
|
||||
},
|
||||
"/requested",
|
||||
).pipe(Effect.forkChild)
|
||||
const signal = yield* Effect.promise(() => entered.promise)
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
const error = yield* Fiber.join(pending).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(String(error)).toContain("Timed out connecting to the server during TUI startup")
|
||||
expect(signal.aborted).toBe(true)
|
||||
}).pipe(Effect.provide(TestClock.layer({}))),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user