From ea4f3c81f47d24ca25f927a6cd0be11506c0845c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 18 Apr 2026 19:34:54 +0900 Subject: [PATCH] fix(tmux): treat pane-already-closed as success in closeTmuxPane After send-keys C-c the subprocess running inside the pane (for example "opencode attach") exits on SIGINT, which causes tmux to destroy the pane automatically. The subsequent kill-pane then returns exit 1 with stderr "can't find pane: %NN" even though the end state is exactly what we wanted. Before this fix closeTmuxPane reported failure for that branch, which kept TmuxSessionManager's retryPendingCloses loop marking the (now deleted) pane as still-pending forever and left stale entries behind in the tracked sessions map. This is the behavior the user observed as "screen opens, streaming runs, but cleanup doesn't finish" when running with tmux.isolation="session". Now we detect the "can't find pane" stderr and return true, treating the auto-destroy path the same as an explicit successful kill. --- src/shared/tmux/tmux-utils/pane-close.test.ts | 26 ++++++++++++++++++- src/shared/tmux/tmux-utils/pane-close.ts | 18 +++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/shared/tmux/tmux-utils/pane-close.test.ts b/src/shared/tmux/tmux-utils/pane-close.test.ts index ca5d74684..b8d5d7887 100644 --- a/src/shared/tmux/tmux-utils/pane-close.test.ts +++ b/src/shared/tmux/tmux-utils/pane-close.test.ts @@ -170,7 +170,7 @@ describe("closeTmuxPane", () => { expect(spawnCalls).toHaveLength(0) }) - it("#given kill-pane fails #when closeTmuxPane called #then returns false", async () => { + it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() queuedProcesses.push(createProcess(0), createProcess(1)) @@ -182,6 +182,30 @@ describe("closeTmuxPane", () => { expect(result).toBe(false) }) + it("#given pane already closed by Ctrl+C (kill-pane reports 'can't find pane') #when closeTmuxPane called #then returns true", async () => { + // given + const closeTmuxPane = await loadCloseTmuxPane() + queuedProcesses.push( + createProcess(0), + { + exited: Promise.resolve(1), + stdout: createClosedStream(), + stderr: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("can't find pane: %42\n")) + controller.close() + }, + }), + }, + ) + + // when + const result = await closeTmuxPane("%42") + + // then + expect(result).toBe(true) + }) + it("#given kill-pane stdout stream waits for drain #when closeTmuxPane called #then returns true once drainer consumes stdout", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts index 76d9dd11b..e62e46296 100644 --- a/src/shared/tmux/tmux-utils/pane-close.ts +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -46,11 +46,19 @@ export async function closeTmuxPane(paneId: string): Promise { killPaneProc.exited, ]) - if (exitCode !== 0) { - log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) - } else { - log("[closeTmuxPane] SUCCESS", { paneId }) + const trimmedStderr = stderr.trim() + const paneAlreadyGone = exitCode !== 0 && /can't find pane/i.test(trimmedStderr) + + if (paneAlreadyGone) { + log("[closeTmuxPane] SUCCESS (pane already closed by Ctrl+C)", { paneId }) + return true } - return exitCode === 0 + if (exitCode !== 0) { + log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: trimmedStderr }) + return false + } + + log("[closeTmuxPane] SUCCESS", { paneId }) + return true }