From e207269cf67058fba0a84be6454cdbaffc2216c6 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:14:59 +0900 Subject: [PATCH] fix(tui): close cross-platform lifecycle races --- tui/src/app.test.ts | 7 ++++++- tui/src/app.ts | 8 +++++++- tui/src/index.ts | 13 +++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index cfc4709b5..cd78bd955 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -22,6 +22,11 @@ function occurrences(frame: string, value: string): number { return frame.split(value).length - 1 } +async function waitUntil(predicate: () => boolean, timeout = 250): Promise { + const deadline = Date.now() + timeout + while (!predicate() && Date.now() < deadline) await Bun.sleep(5) +} + function client(sent: string[] = []) { return { activeChatId: "chat", @@ -113,7 +118,7 @@ describe("NanobotTui layout", () => { composer.setText("你") composer.submit() setTimeout(() => composer.setText("你好"), 0) - await Bun.sleep(10) + await waitUntil(() => sent.length > 0) expect(sent).toEqual(["你好"]) }) diff --git a/tui/src/app.ts b/tui/src/app.ts index 4fbc013db..28f492f56 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -164,6 +164,7 @@ export class NanobotTui { private shimmerFrame = 0 private shimmerTimer: ReturnType | null = null private submitPending = false + private submitGeneration = 0 private readonly promptHistory: string[] = [] private historyCursor = 0 private historyDraft = "" @@ -311,14 +312,17 @@ export class NanobotTui { private deferSubmit(): void { if (this.submitPending) return this.submitPending = true + const generation = ++this.submitGeneration setTimeout(() => setTimeout(() => { + if (generation !== this.submitGeneration) return this.submitPending = false + if (this.composer.isDestroyed) return this.submit() }, 0), 0) } private submit(): void { - if (this.quitting) return + if (this.quitting || this.composer.isDestroyed) return const content = this.composer.plainText.trim() if (!content) return if (!this.ready) { @@ -658,6 +662,8 @@ export class NanobotTui { private quit(): void { if (this.quitting) return this.quitting = true + this.submitGeneration += 1 + this.submitPending = false this.client.close() this.renderer.destroy() } diff --git a/tui/src/index.ts b/tui/src/index.ts index 5258e1672..851e21f41 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -17,17 +17,20 @@ const options: AppOptions = { access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", } -const app = await NanobotTui.create(options) +let app: NanobotTui | undefined +let shuttingDown = false const shutdown = (code = 0) => { - app.stop() + if (shuttingDown) return + shuttingDown = true + app?.stop() process.exitCode = code } for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) { process.once(signal, () => shutdown()) } -process.once("exit", () => app.stop()) +process.once("exit", () => app?.stop()) process.once("uncaughtException", (error) => { shutdown(1) process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`) @@ -37,4 +40,6 @@ process.once("unhandledRejection", (error) => { process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`) }) -app.start() +app = await NanobotTui.create(options) +if (shuttingDown) app.stop() +else app.start()