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.
This commit is contained in:
YeonGyu-Kim
2026-04-18 19:34:54 +09:00
parent b9d2acdcf9
commit ea4f3c81f4
2 changed files with 38 additions and 6 deletions
+25 -1
View File
@@ -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<Uint8Array>({
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()
+13 -5
View File
@@ -46,11 +46,19 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
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
}