From 211f8d1d0f9269c26864ae7e96691f5ad36729f7 Mon Sep 17 00:00:00 2001 From: H-TTTTT <36735327+H-TTTTT@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:05:50 +0800 Subject: [PATCH] feat(cli): show startup progress before TUI --- packages/cli/src/commands/handlers/default.ts | 82 ++-- packages/cli/src/ui/startup.test.ts | 434 ++++++++++++++++++ packages/cli/src/ui/startup.ts | 164 +++++++ packages/cli/src/ui/timeline.tsx | 20 +- packages/tui/src/app.tsx | 7 + packages/tui/test/app-lifecycle.test.tsx | 141 +++++- 6 files changed, 804 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/ui/startup.test.ts create mode 100644 packages/cli/src/ui/startup.ts diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 5e5509ceb017..ad6df61285c9 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -8,49 +8,53 @@ import { Runtime } from "../../framework/runtime" import { Effect, Option } from "effect" import { Server } from "../../services/server" import { Updater } from "../../services/updater" +import { withStartupProgress } from "../../ui/startup" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const requestedDirectory = Option.getOrUndefined(input.directory) if (requestedDirectory !== undefined) process.chdir(requestedDirectory) - const updater = yield* Updater.Service - yield* updater.check().pipe(Effect.forkScoped) - const server = yield* Server.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - onStart: (reason) => - process.stderr.write( - reason === "version-mismatch" - ? "Restarting background server (version mismatch)...\n" - : "Starting background server...\n", - ), - }) - const config = TuiConfig.resolve({}, { terminalSuspend: false }) - let disposeSlots: (() => void) | undefined - const runFork = Effect.runForkWith(yield* Effect.context()) - yield* run({ - server, - args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, - config, - log: (level, message, tags) => { - const effect = - level === "debug" - ? Effect.logDebug(message, tags) - : level === "warn" - ? Effect.logWarning(message, tags) - : level === "error" - ? Effect.logError(message, tags) - : Effect.logInfo(message, tags) - runFork(effect) - }, - pluginHost: { - async start(pluginInput) { - disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) - }, - async dispose() { - disposeSlots?.() - }, - }, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) + const serverURL = Option.getOrUndefined(input.server) + yield* withStartupProgress( + (startup) => + Effect.gen(function* () { + const updater = yield* Updater.Service + yield* updater.check().pipe(Effect.forkScoped) + const server = yield* Server.resolve({ + server: serverURL, + standalone: input.standalone, + onStart: startup.onServerStart, + }) + const config = TuiConfig.resolve({}, { terminalSuspend: false }) + let disposeSlots: (() => void) | undefined + const runFork = Effect.runForkWith(yield* Effect.context()) + yield* run({ + server, + args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + config, + releaseTerminal: startup.releaseTerminal, + log: (level, message, tags) => { + const effect = + level === "debug" + ? Effect.logDebug(message, tags) + : level === "warn" + ? Effect.logWarning(message, tags) + : level === "error" + ? Effect.logError(message, tags) + : Effect.logInfo(message, tags) + runFork(effect) + }, + pluginHost: { + async start(pluginInput) { + disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) + }, + async dispose() { + disposeSlots?.() + }, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) + }), + { enabled: serverURL === undefined }, + ) }), ) diff --git a/packages/cli/src/ui/startup.test.ts b/packages/cli/src/ui/startup.test.ts new file mode 100644 index 000000000000..2b86ef22dae1 --- /dev/null +++ b/packages/cli/src/ui/startup.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Fiber } from "effect" +import { withStartupProgress } from "./startup" + +describe("startup progress", () => { + test("fast startup releases without creating a progress host", async () => { + const timer = manualTimer() + const progress = progressHost() + const events: string[] = [] + let created = 0 + + await Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + events.push("preflight") + await startup.releaseTerminal() + events.push("renderer") + }), + interactive(timer, async () => { + created++ + return progress.host + }), + ), + ) + + expect(await timer.scheduled).toBe(500) + expect(timer.cancelled()).toBe(true) + expect(created).toBe(0) + expect(progress.closed()).toBe(0) + expect(events).toEqual(["preflight", "renderer"]) + }) + + test("delayed startup stays visible through preflight", async () => { + const timer = manualTimer() + const events: string[] = [] + const progress = progressHost({ events }) + const preflight = Promise.withResolvers() + const running = Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + events.push("server") + await preflight.promise + events.push("preflight") + await startup.releaseTerminal() + events.push("renderer") + }), + interactive(timer, async () => { + events.push("create") + return progress.host + }), + ), + ) + + await timer.scheduled + timer.fire() + expect(await progress.shown).toBe("Starting OpenCode...") + expect(progress.closed()).toBe(0) + + preflight.resolve() + await running + expect(events).toEqual(["server", "create", "pending", "preflight", "close", "renderer"]) + }) + + test("close settles an active pending write before renderer handoff", async () => { + const timer = manualTimer() + const events: string[] = [] + const pending = Promise.withResolvers() + const progress = progressHost({ + events, + pending: () => pending.promise, + close: async () => pending.resolve(), + }) + const preflight = Promise.withResolvers() + const running = Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + await preflight.promise + await startup.releaseTerminal() + events.push("renderer") + }), + interactive(timer, async () => progress.host), + ), + ) + + await timer.scheduled + timer.fire() + await progress.shown + preflight.resolve() + await running + + expect(events).toEqual(["pending", "close", "renderer"]) + }) + + test("caller interruption closes visible progress", async () => { + const timer = manualTimer() + const progress = progressHost() + const fiber = Effect.runFork( + withStartupProgress( + () => Effect.never, + interactive(timer, async () => progress.host), + ), + ) + + await timer.scheduled + timer.fire() + await progress.shown + await Effect.runPromise(Fiber.interrupt(fiber)) + + expect(progress.closed()).toBe(1) + }) + + test("host abort interrupts startup instead of failing it", async () => { + const timer = manualTimer() + const progress = progressHost() + const running = Effect.runPromiseExit( + withStartupProgress( + () => Effect.never, + interactive(timer, async () => progress.host), + ), + ) + + await timer.scheduled + timer.fire() + await progress.shown + progress.controller.abort() + + const exit = await running + if (Exit.isSuccess(exit)) throw new Error("Expected startup interruption") + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + expect(progress.closed()).toBe(1) + }) + + test("handoff waits for in-flight host creation and closes the late host", async () => { + const timer = manualTimer() + const events: string[] = [] + const progress = progressHost({ events }) + const created = Promise.withResolvers() + const creating = Promise.withResolvers() + const preflight = Promise.withResolvers() + const running = Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + await preflight.promise + await startup.releaseTerminal() + events.push("renderer") + }), + interactive(timer, async (signal) => { + events.push("create") + creating.resolve(signal) + return created.promise + }), + ), + ) + + await timer.scheduled + timer.fire() + const signal = await creating.promise + preflight.resolve() + await Promise.resolve() + expect(signal.aborted).toBe(true) + expect(events).toEqual(["create"]) + + created.resolve(progress.host) + await running + expect(events).toEqual(["create", "close", "renderer"]) + }) + + test("handoff timeout prevents the main renderer and closes a later host", async () => { + const timer = manualTimer() + const progress = progressHost() + const created = Promise.withResolvers() + const creating = Promise.withResolvers() + const preflight = Promise.withResolvers() + let rendered = false + const running = Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + await preflight.promise + await startup.releaseTerminal() + rendered = true + }), + { + ...interactive(timer, async () => { + creating.resolve() + return created.promise + }), + handoffTimeout: 1, + }, + ), + ) + + await timer.scheduled + timer.fire() + await creating.promise + preflight.resolve() + await expectFailure(running, "Timed out stopping startup progress") + expect(rendered).toBe(false) + + created.resolve(progress.host) + await waitFor(() => progress.closed() === 1) + }) + + test("close failure prevents a second renderer", async () => { + const timer = manualTimer() + const error = new Error("progress teardown failed") + const progress = progressHost({ close: async () => Promise.reject(error) }) + const preflight = Promise.withResolvers() + let rendered = false + const running = Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + await preflight.promise + await startup.releaseTerminal() + rendered = true + }), + interactive(timer, async () => progress.host), + ), + ) + + await timer.scheduled + timer.fire() + await progress.shown + preflight.resolve() + + await expectFailure(running, error.message) + expect(rendered).toBe(false) + }) + + test("startup and teardown errors are both preserved", async () => { + const timer = manualTimer() + const startupError = new Error("server failed") + const closeError = new Error("progress teardown failed") + const progress = progressHost({ close: async () => Promise.reject(closeError) }) + const fail = Promise.withResolvers() + const running = Effect.runPromiseExit( + withStartupProgress( + () => Effect.promise(() => fail.promise).pipe(Effect.andThen(Effect.fail(startupError))), + interactive(timer, async () => progress.host), + ), + ) + + await timer.scheduled + timer.fire() + await progress.shown + fail.resolve() + + const exit = await running + if (Exit.isSuccess(exit)) throw new Error("Expected startup failure") + const message = Cause.pretty(exit.cause) + expect(message).toContain(startupError.message) + expect(message).toContain(closeError.message) + }) + + test("a stale timer cannot create a host after handoff", async () => { + const timer = manualTimer() + let created = 0 + await Effect.runPromise( + withStartupProgress( + (startup) => Effect.promise(() => startup.releaseTerminal()), + interactive(timer, async () => { + created++ + return progressHost().host + }), + ), + ) + + await timer.scheduled + timer.fire(true) + await Promise.resolve() + expect(created).toBe(0) + }) + + test("only two TTY streams use renderer progress; other combinations write plain diagnostics", async () => { + const combinations = [ + { stdin: true, stdout: true, renderer: true }, + { stdin: true, stdout: false, renderer: false }, + { stdin: false, stdout: true, renderer: false }, + { stdin: false, stdout: false, renderer: false }, + ] + + for (const combination of combinations) { + const timer = manualTimer() + const writes: string[] = [] + let created = 0 + await Effect.runPromise( + withStartupProgress( + (startup) => + attempt(async () => { + startup.onServerStart("missing") + await startup.releaseTerminal() + }), + { + terminal: combination, + schedule: timer.schedule, + write: (text) => writes.push(text), + create: async () => { + created++ + return progressHost().host + }, + }, + ), + ) + + expect(timer.delays()).toEqual(combination.renderer ? [500] : []) + expect(writes).toEqual(combination.renderer ? [] : ["Starting background server...\n"]) + expect(created).toBe(0) + } + }) + + test("disabled explicit-server mode never takes terminal ownership", async () => { + const timer = manualTimer() + const writes: string[] = [] + let created = 0 + await Effect.runPromise( + withStartupProgress((startup) => Effect.promise(() => startup.releaseTerminal()), { + enabled: false, + terminal: { stdin: true, stdout: true }, + schedule: timer.schedule, + write: (text) => writes.push(text), + create: async () => { + created++ + return progressHost().host + }, + }), + ) + + expect(timer.delays()).toEqual([]) + expect(writes).toEqual([]) + expect(created).toBe(0) + }) +}) + +function interactive( + timer: ReturnType, + create: (signal: AbortSignal) => Promise["host"]>, +) { + return { + terminal: { stdin: true, stdout: true }, + schedule: timer.schedule, + create, + } +} + +function manualTimer() { + const scheduled = Promise.withResolvers() + const delays: number[] = [] + let callback: (() => void) | undefined + let cancelled = false + const schedule = (next: () => void, delay: number) => { + callback = next + delays.push(delay) + scheduled.resolve(delay) + return () => { + cancelled = true + } + } + return { + scheduled: scheduled.promise, + schedule, + fire(stale = false) { + if (stale || !cancelled) callback?.() + }, + cancelled() { + return cancelled + }, + delays() { + return delays + }, + } +} + +function progressHost( + input: { + readonly events?: string[] + readonly pending?: () => Promise + readonly close?: () => Promise + } = {}, +) { + const shown = Promise.withResolvers() + const controller = new AbortController() + const events = input.events ?? [] + let closed = 0 + return { + shown: shown.promise, + controller, + host: { + signal: controller.signal, + async pending(text: string) { + events.push("pending") + shown.resolve(text) + await input.pending?.() + }, + async close() { + closed++ + events.push("close") + await input.close?.() + }, + }, + closed() { + return closed + }, + } +} + +function attempt(task: () => Promise) { + return Effect.tryPromise({ try: task, catch: toError }) +} + +async function expectFailure(task: Promise, message: string) { + const error = await task.then( + () => undefined, + (cause: unknown) => cause, + ) + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain(message) +} + +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 100; attempt++) { + if (check()) return + await Bun.sleep(1) + } + throw new Error("Condition was not met") +} + +function toError(cause: unknown) { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/packages/cli/src/ui/startup.ts b/packages/cli/src/ui/startup.ts new file mode 100644 index 000000000000..aa3dba2a0e92 --- /dev/null +++ b/packages/cli/src/ui/startup.ts @@ -0,0 +1,164 @@ +import { Cause, Effect, Exit, Option } from "effect" +import type { TimelineHost } from "./timeline" + +type StartupProgressHost = Pick + +type StartupProgress = { + readonly releaseTerminal: () => Promise + readonly onServerStart: (reason: "missing" | "version-mismatch") => void +} + +type StartupProgressOptions = { + readonly enabled?: boolean + readonly terminal?: { readonly stdin: boolean | undefined; readonly stdout: boolean | undefined } + readonly create?: (signal: AbortSignal) => Promise + readonly handoffTimeout?: number + readonly schedule?: (callback: () => void, delay: number) => () => void + readonly write?: (text: string) => void +} + +const DELAY = 500 +const HANDOFF_TIMEOUT = 5_000 +const MESSAGE = "Starting OpenCode..." + +export function withStartupProgress( + use: (progress: StartupProgress) => Effect.Effect, + options: StartupProgressOptions = {}, +) { + return Effect.acquireUseRelease( + Effect.sync(() => createProgress(options)), + (progress) => use(progress).pipe(Effect.raceFirst(interruptOnAbort(progress.signal))), + (progress, exit) => + Effect.tryPromise({ + try: progress.releaseTerminal, + catch: toError, + }).pipe( + Effect.catchCause((cause) => + Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) === + Option.getOrUndefined(Cause.findErrorOption(cause)) + ? Effect.void + : Effect.failCause(Cause.combine(exit.cause, cause)) + : Effect.failCause(cause), + ), + ), + ) +} + +function createProgress(options: StartupProgressOptions) { + const terminal = options.terminal ?? { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY } + const interactive = options.enabled !== false && terminal.stdin === true && terminal.stdout === true + const controller = new AbortController() + const creation = new AbortController() + const stopReason = new Error("Startup progress stopped") + stopReason.name = "AbortError" + let closed = false + let host: StartupProgressHost | undefined + let task: Promise | undefined + let closeTask: Promise | undefined + let detach: (() => void) | undefined + + const open = () => { + if (closed || task) return + task = (async () => { + const next = await (options.create ?? createTimeline)(creation.signal) + host = next + if (closed) { + await next.close() + return + } + + const abort = () => controller.abort(next.signal.reason) + if (next.signal.aborted) abort() + else { + next.signal.addEventListener("abort", abort, { once: true }) + detach = () => next.signal.removeEventListener("abort", abort) + } + await next.pending(MESSAGE) + })().catch((cause) => { + if (closed && cause === stopReason) return + throw cause + }) + void task.catch(() => {}) + } + + const cancel = interactive ? (options.schedule ?? schedule)(open, DELAY) : undefined + const releaseTerminal = () => { + if (closeTask) return closeTask + closed = true + cancel?.() + creation.abort(stopReason) + closeTask = (async () => { + const active = host + if (!active) { + if (task) await withTimeout(task, options.handoffTimeout ?? HANDOFF_TIMEOUT) + return + } + + detach?.() + detach = undefined + const [opened, stopped] = await withTimeout( + Promise.allSettled([task ?? Promise.resolve(), Promise.resolve().then(() => active.close())]), + options.handoffTimeout ?? HANDOFF_TIMEOUT, + ) + if (opened.status === "rejected" && stopped.status === "rejected") + throw new AggregateError([opened.reason, stopped.reason], "Failed to stop startup progress") + if (stopped.status === "rejected") throw stopped.reason + if (opened.status === "rejected") throw opened.reason + })() + return closeTask + } + + const write = options.write ?? ((text: string) => void process.stderr.write(text)) + return { + signal: controller.signal, + releaseTerminal, + onServerStart(reason: "missing" | "version-mismatch") { + if (interactive) return + write( + reason === "version-mismatch" + ? "Restarting background server (version mismatch)...\n" + : "Starting background server...\n", + ) + }, + } +} + +function interruptOnAbort(signal: AbortSignal) { + return Effect.callback((resume) => { + const abort = () => resume(Effect.interrupt) + if (signal.aborted) abort() + else signal.addEventListener("abort", abort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", abort)) + }) +} + +async function createTimeline(signal: AbortSignal) { + const { createTimelineHost } = await import("./timeline") + return createTimelineHost({ signal }) +} + +function schedule(callback: () => void, delay: number) { + const timer = setTimeout(callback, delay) + timer.unref() + return () => clearTimeout(timer) +} + +function withTimeout(task: Promise, timeout: number) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out stopping startup progress")), timeout) + timer.unref() + const done = (callback: () => void) => { + clearTimeout(timer) + callback() + } + void task.then( + (value) => done(() => resolve(value)), + (error) => done(() => reject(error)), + ) + }) +} + +function toError(cause: unknown) { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/packages/cli/src/ui/timeline.tsx b/packages/cli/src/ui/timeline.tsx index 8c7e6612c67c..ef7840dc3bef 100644 --- a/packages/cli/src/ui/timeline.tsx +++ b/packages/cli/src/ui/timeline.tsx @@ -106,7 +106,12 @@ async function shutdown(renderer: CliRenderer): Promise { } } -export async function createTimelineHost(): Promise { +function abortReason(signal: AbortSignal) { + return signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError") +} + +export async function createTimelineHost(options: { readonly signal?: AbortSignal } = {}): Promise { + if (options.signal?.aborted) throw abortReason(options.signal) const stdout = process.stdout const controller = new AbortController() const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"] @@ -141,7 +146,7 @@ export async function createTimelineHost(): Promise { if (closeTask) return closeTask closed = true closeTask = (async () => { - await active?.catch(() => { }) + await active?.catch(() => {}) signals.forEach((signal) => process.off(signal, cancel)) })() return closeTask @@ -177,6 +182,12 @@ export async function createTimelineHost(): Promise { consoleMode: "disabled", clearOnShutdown: false, }) + if (options.signal?.aborted) { + const activeRenderer = renderer + renderer = undefined + await shutdown(activeRenderer) + throw abortReason(options.signal) + } const activeRenderer = renderer const [pending, setPending] = createSignal() const renderTask = render(() => , activeRenderer) @@ -211,10 +222,11 @@ export async function createTimelineHost(): Promise { if (closeTask) return closeTask closed = true closeTask = (async () => { - await active?.catch(() => { }) try { - await shutdown(activeRenderer) + const teardown = shutdown(activeRenderer) + const [result] = await Promise.allSettled([teardown, bounded(active ?? Promise.resolve())]) await bounded(renderTask) + if (result.status === "rejected") throw result.reason } finally { signals.forEach((signal) => process.off(signal, cancel)) } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index fd6e4ea4de48..7ade739c7034 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -152,6 +152,7 @@ export type TuiInput = { } args: Args config: TuiConfig.Resolved + releaseTerminal?: () => Promise onSnapshot?: () => Promise pluginHost: TuiPluginHost log?: LogSink @@ -212,6 +213,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { + if (input.releaseTerminal) { + yield* Effect.tryPromise({ + try: () => input.releaseTerminal!(), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + } const renderer = yield* Effect.acquireRelease( Effect.tryPromise({ try: async () => { diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index c1530ee18944..ef63ade18d31 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,12 +1,151 @@ import { expect, mock, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createTestRenderer } from "@opentui/core/testing" -import { Effect } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { createTuiResolvedConfig } from "./fixture/tui-runtime" import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" +test("releases terminal ownership after API preflight and before renderer creation", async () => { + const core = await import("@opentui/core") + const events: string[] = [] + const rendererError = new Error("renderer reached") + await mock.module("@opentui/core", () => ({ + ...core, + createCliRenderer: async () => { + events.push("renderer") + throw rendererError + }, + })) + const release = Promise.withResolvers() + const handoff = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/fs/list") events.push("preflight") + return undefined + }) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromiseExit( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + args: {}, + releaseTerminal: async () => { + events.push("handoff") + handoff.resolve() + await release.promise + events.push("released") + }, + pluginHost: { + async start() {}, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + + await handoff.promise + expect(events).toEqual(["preflight", "handoff"]) + release.resolve() + + const exit = await task + if (Exit.isSuccess(exit)) throw new Error("Expected renderer failure") + expect(Cause.pretty(exit.cause)).toContain(rendererError.message) + expect(events).toEqual(["preflight", "handoff", "released", "renderer"]) + } finally { + release.resolve() + await server.stop() + mock.restore() + } +}) + +test("does not create a renderer after terminal handoff is interrupted", async () => { + const core = await import("@opentui/core") + let renders = 0 + await mock.module("@opentui/core", () => ({ + ...core, + createCliRenderer: async () => { + renders++ + throw new Error("renderer should not start") + }, + })) + const release = Promise.withResolvers() + const handoff = Promise.withResolvers() + const calls = createFetch() + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const fiber = Effect.runFork( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + args: {}, + releaseTerminal: async () => { + handoff.resolve() + await release.promise + }, + pluginHost: { + async start() {}, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + + await handoff.promise + await Effect.runPromise(Fiber.interrupt(fiber)) + release.resolve() + await Promise.resolve() + expect(renders).toBe(0) + } finally { + release.resolve() + await server.stop() + mock.restore() + } +}) + +test("does not create a renderer when terminal handoff fails", async () => { + const core = await import("@opentui/core") + const handoffError = new Error("terminal handoff failed") + let renders = 0 + await mock.module("@opentui/core", () => ({ + ...core, + createCliRenderer: async () => { + renders++ + throw new Error("renderer should not start") + }, + })) + const calls = createFetch() + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const exit = await Effect.runPromiseExit( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + args: {}, + releaseTerminal: async () => { + throw handoffError + }, + pluginHost: { + async start() {}, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + + if (Exit.isSuccess(exit)) throw new Error("Expected terminal handoff failure") + expect(Cause.pretty(exit.cause)).toContain(handoffError.message) + expect(renders).toBe(0) + } finally { + await server.stop() + mock.restore() + } +}) + test("SIGHUP clears title and disposes scoped resources once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core")