diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9fe99daf595..7323e9a1a8d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 } > - + diff --git a/packages/tui/src/bootstrap.ts b/packages/tui/src/bootstrap.ts new file mode 100644 index 00000000000..1300ee9a2da --- /dev/null +++ b/packages/tui/src/bootstrap.ts @@ -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 } + }, + 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 } +}) diff --git a/packages/tui/test/app-bootstrap.test.tsx b/packages/tui/test/app-bootstrap.test.tsx new file mode 100644 index 00000000000..4ace457ce68 --- /dev/null +++ b/packages/tui/test/app-bootstrap.test.tsx @@ -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() + 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 }) + } +}) diff --git a/packages/tui/test/bootstrap.test.ts b/packages/tui/test/bootstrap.test.ts new file mode 100644 index 00000000000..e4add7f1f28 --- /dev/null +++ b/packages/tui/test/bootstrap.test.ts @@ -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() + 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() + 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({}))), + ) +})