diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0654df23e62..c2ebf0abb43 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2849,11 +2849,6 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } -/** Caps the hoist ledger. A live tab is protected by the local-session check, not by this cap, so - * eviction is safe for open tabs; the cost of evicting an id is that its tab could be re-hoisted - * once if it is later closed while the partition still lists it. */ -const MAX_HOISTED_TAB_IDS = 512 - /** Why a `mayCreate: false` binding write was refused. `noTab` means the pane has no durable tab, * so publishing it anywhere would be publishing something that does not exist. */ export type PtyBindingRefusalReason = 'noTab' | 'noMembership' @@ -3021,16 +3016,34 @@ export function hoistSshPartitionsIntoLocalSession( } } if (changed) { - // Bounded, newest-last. A tab id can be reused by the CLI after its tab closes, and an - // unbounded list would both grow for the life of the profile and strand such a pane forever. - // KNOWN COST, and it is worse than it reads: an evicted id can be re-hoisted once, and since - // the unified ledger gates the plane the tab bar renders from, that means a long-closed tab - // reappearing ON SCREEN. Needs >512 hoisted tabs. Tracked, not fixed here. - const bounded = [...alreadyHoisted].slice(-MAX_HOISTED_TAB_IDS) + // Pruned by RELEVANCE, not recency. A ledger entry only has a job while the partition still + // offers that tab: it is what stops a tab the user closed being folded back in. Slicing to the + // newest N evicted exactly the entries a long-lived profile needs most, and a re-hoisted id + // returns a closed tab to the plane the tab bar renders from — on screen. Dropping ids no + // partition mentions any more bounds growth by what the host actually offers, and can never + // evict an entry that is still load-bearing. + const offeredTabIds = new Set() + for (const [hostId, hostSession] of Object.entries(partitions)) { + if (!hostId.startsWith('ssh:') || !hostSession) { + continue + } + for (const tabs of Object.values(hostSession.tabsByWorktree ?? {})) { + for (const tab of tabs ?? []) { + offeredTabIds.add(tab.id) + } + } + for (const tabs of Object.values(hostSession.unifiedTabs ?? {})) { + for (const tab of tabs ?? []) { + offeredTabIds.add(tab.id) + } + } + } state.settings = { ...state.settings, - sshHoistedTabIds: bounded, - sshHoistedUnifiedTabIds: [...alreadyHoistedUnified].slice(-MAX_HOISTED_TAB_IDS) + sshHoistedTabIds: [...alreadyHoisted].filter((tabId) => offeredTabIds.has(tabId)), + sshHoistedUnifiedTabIds: [...alreadyHoistedUnified].filter((tabId) => + offeredTabIds.has(tabId) + ) } } return changed diff --git a/src/main/ssh-partition-upgrade-tab-loss.test.ts b/src/main/ssh-partition-upgrade-tab-loss.test.ts index c9530a200b9..f485710af8f 100644 --- a/src/main/ssh-partition-upgrade-tab-loss.test.ts +++ b/src/main/ssh-partition-upgrade-tab-loss.test.ts @@ -592,4 +592,43 @@ describe('an SSH pane whose membership was persisted into the ssh partition', () 'a tab the user closed came back in the tab bar' ).toHaveLength(0) }) + + // The ledger is what stops a closed tab being folded back in, so an entry must survive exactly + // as long as the partition still offers that tab. Capping by recency evicted the entries a + // long-lived profile needs most, and a re-hoisted id returns a closed tab to the plane the tab + // bar renders from — on screen. + it('keeps ledger entries for every tab the partition still offers, however many there are', async () => { + const before = await createStore() + const many = paneSession() + const ids = Array.from({ length: 600 }, (_, index) => `tab-${index}`) + many.tabsByWorktree[WORKTREE] = ids.map((id) => ({ + ...many.tabsByWorktree[WORKTREE]![0]!, + id + })) + many.unifiedTabs = { [WORKTREE]: ids.map((id) => unifiedTab(id)) } as typeof many.unifiedTabs + before.setWorkspaceSession(many as never, SSH_PARTITION) + before.flushOrThrow() + stripHoistMarker() + + const upgraded = await reopenStore() + expect( + upgraded.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? [], + 'the tabs never hoisted, so eviction cannot be observed' + ).toHaveLength(ids.length) + + // The user closes them all. + upgraded.setWorkspaceSession({ + ...upgraded.getWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [] }, + unifiedTabs: { [WORKTREE]: [] } + } as never) + upgraded.flushOrThrow() + + const relaunched = await reopenStore() + + expect( + relaunched.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? [], + 'an evicted ledger entry let a closed tab reappear in the tab bar' + ).toHaveLength(0) + }) })