fix(tui): recover managed connections during bootstrap

This commit is contained in:
Kit Langton
2026-09-22 22:32:19 -07:00
parent 00738c5b2d
commit 0de099c826
4 changed files with 350 additions and 8 deletions
+8 -8
View File
@@ -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>
+45
View File
@@ -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 }
})
+97
View File
@@ -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 })
}
})
+200
View File
@@ -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({}))),
)
})