Skip to content

Commit 058c803

Browse files
Merge pull request #335 from corbitsdev/cl-5554-use-after-free-when-quitting-during-an-mention-lookup
Guard the @-mention lookup against a disposed shell
2 parents cf3bb84 + 22c98d8 commit 058c803

3 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/tui-opentui/mention-popup.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,39 @@ describe("@ popup narrows as you type", () => {
123123
})
124124
})
125125

126+
test("quitting mid-lookup does not write into the disposed shell", async () => {
127+
await withTestRenderer(
128+
async (h) => {
129+
const shell = createAppShell(h.renderer, {
130+
terminal: { columns: 80, rows: 24 },
131+
wireKeys: false,
132+
run: "idle",
133+
})
134+
let resolveLookup: (entries: readonly string[]) => void = () => {}
135+
setMentionSuggestionSource(
136+
shell,
137+
() =>
138+
new Promise<readonly string[]>((resolve) => {
139+
resolveLookup = resolve
140+
}),
141+
)
142+
143+
shell.prompt.value = "read @"
144+
shell.prompt.cursorOffset = shell.prompt.value.length
145+
const pending = openAtMentionSuggestions(shell)
146+
147+
// The operator quits before the filesystem lookup answers.
148+
shell.dispose()
149+
resolveLookup(["AGENTS.md", "README.md"])
150+
151+
await expect(pending).resolves.toBe(false)
152+
expect(shell.overlayKind).toBeNull()
153+
expect(isMentionPopupOpen(shell)).toBe(false)
154+
},
155+
{ width: 80, height: 24 },
156+
)
157+
})
158+
126159
test("no match closes the popup and leaves the typed text", async () => {
127160
await withShell(async (shell) => {
128161
await openAt(shell, "@")

src/tui-opentui/prompt-features.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ describe("image attachments", () => {
7171
})
7272
})
7373

74+
test("quitting mid-read does not attach into the disposed shell", async () => {
75+
await withTestRenderer(
76+
async (h) => {
77+
const shell = createAppShell(h.renderer, {
78+
terminal: { columns: 80, rows: 24 },
79+
wireKeys: true,
80+
run: "idle",
81+
})
82+
let resolveRead: (r: { ok: true; attachment: PendingImageAttachment }) => void =
83+
() => {}
84+
setPromptImageSource(
85+
shell,
86+
() =>
87+
new Promise((resolve) => {
88+
resolveRead = resolve
89+
}),
90+
)
91+
92+
const pending = attachClipboardImage(shell)
93+
94+
// The operator quits before the clipboard read answers.
95+
shell.dispose()
96+
resolveRead({ ok: true, attachment: CLIP })
97+
98+
expect(await pending).toBe(false)
99+
expect(shell.pendingAttachments).toEqual([])
100+
},
101+
{ width: 80, height: 24 },
102+
)
103+
})
104+
74105
// Raw control bytes, not a synthetic KeyEvent: a binding that never matches
75106
// what the terminal actually writes looks correct in the catalog and fails
76107
// silently in use.

src/tui-opentui/shell.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,9 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
863863
const source = shellPromptImageSource.get(shell) ?? readClipboardImage
864864
setStatusFlash(shell, "reading clipboard image…")
865865
const result = await source()
866+
// Quitting while the clipboard read is pending tears down the shell's
867+
// renderables; a stale continuation must not mutate them on resume.
868+
if (shell.disposed) return false
866869
if (!result.ok) {
867870
setStatusFlash(shell, `image attach failed: ${result.reason}`)
868871
return false
@@ -3931,11 +3934,15 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean
39313934
await source(token.dir),
39323935
token.fragment,
39333936
)
3937+
// Quitting mid-lookup tears down the renderer/TextBuffer this function
3938+
// writes into below; a resolved-but-stale lookup must not touch them.
3939+
if (shell.disposed) return false
39343940
// The source caps how many entries it returns per directory, so a large
39353941
// directory can cap out before the interior match appears. Asking it to do
39363942
// its own prefix filter puts that cap after the narrowing instead of before.
39373943
if (suggestions.length === 0 && token.fragment.length > 0) {
39383944
suggestions = await source(at.prefix)
3945+
if (shell.disposed) return false
39393946
}
39403947
if (mentionGenerations.get(shell) !== generation) return false
39413948

0 commit comments

Comments
 (0)