From 6085e29d00444845fdb66b2560fa9ff2a758d8d5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 20:45:39 -0700 Subject: [PATCH 1/2] Guard the @-mention lookup against a shell disposed mid-flight Quitting while an @-mention filesystem lookup is in flight let the resolved promise write suggestions into a torn-down shell, touching the freed renderer/TextBuffer. Check shell.disposed after each await in openAtMentionSuggestions and bail before any further write. --- src/tui-opentui/mention-popup.test.ts | 33 +++++++++++++++++++++++++++ src/tui-opentui/shell.ts | 4 ++++ 2 files changed, 37 insertions(+) diff --git a/src/tui-opentui/mention-popup.test.ts b/src/tui-opentui/mention-popup.test.ts index 3769c0199..b903e9dcc 100644 --- a/src/tui-opentui/mention-popup.test.ts +++ b/src/tui-opentui/mention-popup.test.ts @@ -123,6 +123,39 @@ describe("@ popup narrows as you type", () => { }) }) + test("quitting mid-lookup does not write into the disposed shell", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + let resolveLookup: (entries: readonly string[]) => void = () => {} + setMentionSuggestionSource( + shell, + () => + new Promise((resolve) => { + resolveLookup = resolve + }), + ) + + shell.prompt.value = "read @" + shell.prompt.cursorOffset = shell.prompt.value.length + const pending = openAtMentionSuggestions(shell) + + // The operator quits before the filesystem lookup answers. + shell.dispose() + resolveLookup(["AGENTS.md", "README.md"]) + + await expect(pending).resolves.toBe(false) + expect(shell.overlayKind).toBeNull() + expect(isMentionPopupOpen(shell)).toBe(false) + }, + { width: 80, height: 24 }, + ) + }) + test("no match closes the popup and leaves the typed text", async () => { await withShell(async (shell) => { await openAt(shell, "@") diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e45110ad5..7340a8fa5 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3931,11 +3931,15 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise 0) { suggestions = await source(at.prefix) + if (shell.disposed) return false } if (mentionGenerations.get(shell) !== generation) return false From 22c98d849b3b929b9936c1cf5d8fa1a943362007 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 20:51:42 -0700 Subject: [PATCH 2/2] Guard the clipboard-image attach continuation against a disposed shell attachClipboardImage was the same shape as the mention lookup: it awaits the clipboard read, then unconditionally attaches into shell.pendingAttachments and flashes status. Check shell.disposed right after the await, before any of that runs. This was the third and last await in shell.ts; the other two (both in openAtMentionSuggestions) were already guarded. --- src/tui-opentui/prompt-features.test.ts | 31 +++++++++++++++++++++++++ src/tui-opentui/shell.ts | 3 +++ 2 files changed, 34 insertions(+) diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 79adc8953..71908da56 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -71,6 +71,37 @@ describe("image attachments", () => { }) }) + test("quitting mid-read does not attach into the disposed shell", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + }) + let resolveRead: (r: { ok: true; attachment: PendingImageAttachment }) => void = + () => {} + setPromptImageSource( + shell, + () => + new Promise((resolve) => { + resolveRead = resolve + }), + ) + + const pending = attachClipboardImage(shell) + + // The operator quits before the clipboard read answers. + shell.dispose() + resolveRead({ ok: true, attachment: CLIP }) + + expect(await pending).toBe(false) + expect(shell.pendingAttachments).toEqual([]) + }, + { width: 80, height: 24 }, + ) + }) + // Raw control bytes, not a synthetic KeyEvent: a binding that never matches // what the terminal actually writes looks correct in the catalog and fails // silently in use. diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 7340a8fa5..8be092865 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -863,6 +863,9 @@ export async function attachClipboardImage(shell: AppShell): Promise { const source = shellPromptImageSource.get(shell) ?? readClipboardImage setStatusFlash(shell, "reading clipboard image…") const result = await source() + // Quitting while the clipboard read is pending tears down the shell's + // renderables; a stale continuation must not mutate them on resume. + if (shell.disposed) return false if (!result.ok) { setStatusFlash(shell, `image attach failed: ${result.reason}`) return false