From e29b0bea77637f1bfc189e29441e374243a32b47 Mon Sep 17 00:00:00 2001 From: Antonio Lourenco Date: Thu, 6 Aug 2026 14:46:29 +0100 Subject: [PATCH] fix: close headless terminal tabs whose persisted session record is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal tab present in the live mobile-session snapshot but absent from the persisted workspace session could never be closed. Every close path routes through closeHeadlessMobileTerminalTab, whose de-persist step threw tab_not_found before the PTY kill and the snapshot prune, so the runtime republished the unchanged snapshot and the tab resurrected on every client. The drift arises from never-persisted background tabs (failed reveal) and from a prior close that de-persisted the row and then failed on a later step. The caller resolves the tab from the live snapshot before this code runs, so a missing persisted row always means drift, never a bad tab id. Treat it as already de-persisted: continue with the PTY kill and the snapshot prune. This also makes the interrupted-close case self-healing — the next close completes the transaction. Fixes #10747 Fixes #12920 Co-Authored-By: Claude Fable 5 --- src/main/runtime/orca-runtime.test.ts | 82 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 27 ++++----- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 432711bc6cc2..2fb6ba6a02fe 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30232,6 +30232,88 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('closes headless terminal parents whose persisted record is missing instead of resurrecting them', async () => { + const layout = makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'pty-a', + [HEADLESS_SECOND_LEAF_ID]: 'pty-b' + }) + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'pty-a', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { 'host-tab': layout } + }) + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-a', + paneTitle: 'A' + }, + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 2, + ptyId: 'pty-b', + paneTitle: 'B' + } + ] + }) + + // Why: warm the live snapshot, then drop the persisted row out-of-band to + // reproduce the persisted/live-state drift (#10747, #12920) — a + // never-persisted background tab or a close that de-persisted then failed. + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + setSession({ + ...getSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalLayoutsByTabId: {} + }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(kill).toHaveBeenCalledWith('pty-a') + expect(kill).toHaveBeenCalledWith('pty-b') + const relisted = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + relisted.tabs.filter((tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab') + ).toEqual([]) + }) + it('closes persisted headless terminal parents before any prior list call', async () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 80a04d605540..110525997365 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7232,11 +7232,7 @@ export class OrcaRuntimeService { } } - private removePersistedHeadlessTerminalTab( - worktreeId: string, - parentTabId: string, - options: { allowMissing?: boolean } = {} - ): string[] { + private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { throw new Error('workspace_session_unavailable') @@ -7246,10 +7242,12 @@ export class OrcaRuntimeService { throw new Error('terminal_tab_pinned') } if (!result.closed) { - if (options.allowMissing) { - return [] - } - throw new Error('tab_not_found') + // Why: the caller resolved the tab from the live snapshot, so a missing + // persisted row is always drift (never-persisted background tab, or an + // earlier close that de-persisted then failed). Aborting here would skip + // the PTY kill and snapshot prune below and the republished snapshot + // would resurrect the tab on every client forever (#10747, #12920). + return [] } this.setWorkspaceSessionForWorktree( worktreeId, @@ -7873,10 +7871,7 @@ export class OrcaRuntimeService { this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) ) { // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { - // Why: the renderer may already have durably removed the tab before acknowledging. - allowMissingPersistedTab: true - }) + this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } @@ -8050,13 +8045,11 @@ export class OrcaRuntimeService { worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, - options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {} + options: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) - const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, { - allowMissing: options.allowMissingPersistedTab - }) + const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) // Why: local provider ids can be reused after restart, so a dormant // persisted id is not kill authority. SSH relay ids remain durable exact // identities even before pane metadata reconnects.