fix(tui): close cross-platform lifecycle races

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 4f12e15237
commit e207269cf6
3 changed files with 22 additions and 6 deletions
+6 -1
View File
@@ -22,6 +22,11 @@ function occurrences(frame: string, value: string): number {
return frame.split(value).length - 1 return frame.split(value).length - 1
} }
async function waitUntil(predicate: () => boolean, timeout = 250): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate() && Date.now() < deadline) await Bun.sleep(5)
}
function client(sent: string[] = []) { function client(sent: string[] = []) {
return { return {
activeChatId: "chat", activeChatId: "chat",
@@ -113,7 +118,7 @@ describe("NanobotTui layout", () => {
composer.setText("你") composer.setText("你")
composer.submit() composer.submit()
setTimeout(() => composer.setText("你好"), 0) setTimeout(() => composer.setText("你好"), 0)
await Bun.sleep(10) await waitUntil(() => sent.length > 0)
expect(sent).toEqual(["你好"]) expect(sent).toEqual(["你好"])
}) })
+7 -1
View File
@@ -164,6 +164,7 @@ export class NanobotTui {
private shimmerFrame = 0 private shimmerFrame = 0
private shimmerTimer: ReturnType<typeof setInterval> | null = null private shimmerTimer: ReturnType<typeof setInterval> | null = null
private submitPending = false private submitPending = false
private submitGeneration = 0
private readonly promptHistory: string[] = [] private readonly promptHistory: string[] = []
private historyCursor = 0 private historyCursor = 0
private historyDraft = "" private historyDraft = ""
@@ -311,14 +312,17 @@ export class NanobotTui {
private deferSubmit(): void { private deferSubmit(): void {
if (this.submitPending) return if (this.submitPending) return
this.submitPending = true this.submitPending = true
const generation = ++this.submitGeneration
setTimeout(() => setTimeout(() => { setTimeout(() => setTimeout(() => {
if (generation !== this.submitGeneration) return
this.submitPending = false this.submitPending = false
if (this.composer.isDestroyed) return
this.submit() this.submit()
}, 0), 0) }, 0), 0)
} }
private submit(): void { private submit(): void {
if (this.quitting) return if (this.quitting || this.composer.isDestroyed) return
const content = this.composer.plainText.trim() const content = this.composer.plainText.trim()
if (!content) return if (!content) return
if (!this.ready) { if (!this.ready) {
@@ -658,6 +662,8 @@ export class NanobotTui {
private quit(): void { private quit(): void {
if (this.quitting) return if (this.quitting) return
this.quitting = true this.quitting = true
this.submitGeneration += 1
this.submitPending = false
this.client.close() this.client.close()
this.renderer.destroy() this.renderer.destroy()
} }
+9 -4
View File
@@ -17,17 +17,20 @@ const options: AppOptions = {
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", 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) => { const shutdown = (code = 0) => {
app.stop() if (shuttingDown) return
shuttingDown = true
app?.stop()
process.exitCode = code process.exitCode = code
} }
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) { for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
process.once(signal, () => shutdown()) process.once(signal, () => shutdown())
} }
process.once("exit", () => app.stop()) process.once("exit", () => app?.stop())
process.once("uncaughtException", (error) => { process.once("uncaughtException", (error) => {
shutdown(1) shutdown(1)
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`) 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`) 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()