Skip to content

Commit e3af2d7

Browse files
committed
Stop TTL flashes from painting a destroyed TUI renderer
Headless tests often tear down the renderer without dispose. A TTL flash armed before that teardown must not write a freed TextBuffer.
1 parent 12b9e6e commit e3af2d7

4 files changed

Lines changed: 44 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- One-shot confirmation flashes (copy, mouse toggle, attach results, reasoning effort, stall recovery) now clear themselves after a short TTL. Rate-limit waits no longer park on the bottom notice row; the durable error stays in the transcript. Live stall notice and landing hold still omit a TTL so they stay until replaced.
19+
- A TTL flash no longer paints chrome after the TUI renderer is destroyed, which crashed parallel TUI tests with `TextBuffer is destroyed`.
20+
1421
## [0.3.1] - 2026-08-24
1522

1623
### Fixed

src/tui/copy-wire.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@ function capturingSchedule(lapse: (() => void)[], expectedMs = RUNTIME_FLASH_MS)
3434
};
3535
}
3636

37+
/** Do not arm a real timer: bun test runs files in one process. */
38+
const ignoreExpiry: FlashSchedule = () => () => {};
39+
3740
describe("Alt+C reaches the injected clipboard", () => {
3841
test("confirming a copy target writes its text", () => {
3942
const clipboard = createRecordingClipboard();
40-
const shell = createAppShell(harness.renderer, { clipboard });
43+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
4144
appendStreamRow(shell, { role: "assistant", text: "copy me" });
4245
expect(enterCopyMode(shell)).toBe(true);
4346
expect(confirmCopySelection(shell)).toBe(true);
@@ -47,7 +50,7 @@ describe("Alt+C reaches the injected clipboard", () => {
4750

4851
test("copy all writes every non-system row", () => {
4952
const clipboard = createRecordingClipboard();
50-
const shell = createAppShell(harness.renderer, { clipboard });
53+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
5154
appendStreamRow(shell, { role: "user", text: "one" });
5255
appendStreamRow(shell, { role: "assistant", text: "two" });
5356
enterCopyMode(shell);
@@ -92,7 +95,7 @@ describe("Alt+C reaches the injected clipboard", () => {
9295
describe("drag-select auto-copy", () => {
9396
test("SELECTION event writes finished text and flashes", () => {
9497
const clipboard = createRecordingClipboard();
95-
const shell = createAppShell(harness.renderer, { clipboard });
98+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
9699
harness.renderer.emit(CliRenderEvents.SELECTION, {
97100
isDragging: false,
98101
getSelectedText: () => "dragged snippet",
@@ -123,7 +126,7 @@ describe("drag-select auto-copy", () => {
123126

124127
test("SELECTION while dragging is a no-op", () => {
125128
const clipboard = createRecordingClipboard();
126-
const shell = createAppShell(harness.renderer, { clipboard });
129+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
127130
harness.renderer.emit(CliRenderEvents.SELECTION, {
128131
isDragging: true,
129132
getSelectedText: () => "partial",
@@ -135,7 +138,7 @@ describe("drag-select auto-copy", () => {
135138

136139
test("empty SELECTION is a no-op", () => {
137140
const clipboard = createRecordingClipboard();
138-
const shell = createAppShell(harness.renderer, { clipboard });
141+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
139142
harness.renderer.emit(CliRenderEvents.SELECTION, {
140143
isDragging: false,
141144
getSelectedText: () => "",
@@ -149,6 +152,7 @@ describe("Alt+M mouse capture", () => {
149152
test("toggles the host port and reports the new state", () => {
150153
let enabled = false;
151154
const shell = createAppShell(harness.renderer, {
155+
flashSchedule: ignoreExpiry,
152156
mouseCapture: {
153157
get: () => enabled,
154158
set: (v) => {
@@ -185,7 +189,7 @@ describe("Alt+M mouse capture", () => {
185189
});
186190

187191
test("reports unavailable when the host exposes no control", () => {
188-
const shell = createAppShell(harness.renderer);
192+
const shell = createAppShell(harness.renderer, { flashSchedule: ignoreExpiry });
189193
expect(toggleMouseCapture(shell)).toBeNull();
190194
expect(shell.statusFlash).toContain("not controllable");
191195
shell.dispose();

src/tui/prompt-chrome.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,28 @@ describe("no permanent hint strip", () => {
317317
});
318318
});
319319

320+
test("a lapsed flash does not paint after the renderer is torn down without dispose", async () => {
321+
await withTestRenderer(async (h) => {
322+
const lapse: (() => void)[] = [];
323+
const shell = createAppShell(h.renderer, {
324+
title: "test",
325+
cwd: "/src/corbits-code",
326+
terminal: { columns: 80, rows: 24 },
327+
wireKeys: false,
328+
flashSchedule: (fn, ms) => {
329+
expect(ms).toBe(RUNTIME_FLASH_MS);
330+
lapse.push(fn);
331+
return () => {};
332+
},
333+
});
334+
setStatusFlash(shell, "copied 3 lines", { ttlMs: RUNTIME_FLASH_MS });
335+
h.destroy();
336+
expect(h.renderer.isDestroyed).toBe(true);
337+
expect(shell.disposed).toBe(false);
338+
expect(() => lapse[0]?.()).not.toThrow();
339+
});
340+
});
341+
320342
test("the keys strip is gone from the frame entirely", async () => {
321343
await withShell((shell) => {
322344
const painted = [

src/tui/shell.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,10 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void {
859859
/** Repaint the prompt borders and the transient notice row from live state. */
860860
export function paintChrome(shell: AppShell): void {
861861
if (shell.disposed) return;
862+
// Headless tests often destroy the renderer without dispose
863+
// (`withTestRenderer` cleanup). A TTL flash armed before that teardown
864+
// must not write a TextBuffer the harness already freed.
865+
if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return;
862866
syncPending(shell);
863867
const notice = noticeText(shell);
864868
shell.notice.content = new StyledText([
@@ -1058,6 +1062,7 @@ export function setStatusFlash(
10581062
paintChrome(shell);
10591063
const ttlMs = options?.ttlMs;
10601064
if (message === null || ttlMs === undefined || ttlMs <= 0) return;
1065+
if (shell.disposed || shell.renderer.isDestroyed) return;
10611066
const schedule = options?.schedule ?? shellFlashSchedules.get(shell) ?? defaultFlashSchedule;
10621067
flashTimers.set(
10631068
shell,

0 commit comments

Comments
 (0)