diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8b53a7d98d6..e167bfa6107 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2849,6 +2849,70 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } + +/** + * Fold SSH pane membership left in `workspaceSessionsByHostId["ssh:"]` into the local + * session, which is the only plane the binding writer maintains. + * + * Why this has to exist: consolidating the two planes removed the writer but not the data. A pane + * the local session has never heard of looks new to `persistPtyBinding`, and reattach passes + * `mayCreate: false`, so the binding is refused and rolled back — for every pane the upgrading + * user had open. Local always wins; the partition only supplies panes local does not already know. + */ +export function hoistSshPartitionsIntoLocalSession(state: PersistedState): boolean { + const partitions = state.workspaceSessionsByHostId + if (!partitions) { + return false + } + const local = state.workspaceSession + if (!local) { + return false + } + let changed = false + for (const [hostId, partition] of Object.entries(partitions)) { + if (!hostId.startsWith('ssh:') || !partition) { + continue + } + for (const [worktreeId, tabs] of Object.entries(partition.tabsByWorktree ?? {})) { + const existing = local.tabsByWorktree?.[worktreeId] ?? [] + const known = new Set(existing.map((tab) => tab.id)) + const missing = (tabs ?? []).filter((tab) => !known.has(tab.id)) + if (missing.length === 0) { + continue + } + local.tabsByWorktree = { ...local.tabsByWorktree, [worktreeId]: [...existing, ...missing] } + changed = true + } + for (const [tabId, layout] of Object.entries(partition.terminalLayoutsByTabId ?? {})) { + if (!layout || local.terminalLayoutsByTabId?.[tabId]) { + continue + } + local.terminalLayoutsByTabId = { ...local.terminalLayoutsByTabId, [tabId]: layout } + changed = true + } + for (const [paneKey, incarnationId] of Object.entries( + partition.terminalPtyIncarnationsByPaneKey ?? {} + )) { + if (local.terminalPtyIncarnationsByPaneKey?.[paneKey] !== undefined) { + continue + } + local.terminalPtyIncarnationsByPaneKey = { + ...local.terminalPtyIncarnationsByPaneKey, + [paneKey]: incarnationId + } + changed = true + } + for (const [worktreeId, tabId] of Object.entries(partition.activeTabIdByWorktree ?? {})) { + if (local.activeTabIdByWorktree?.[worktreeId] !== undefined) { + continue + } + local.activeTabIdByWorktree = { ...local.activeTabIdByWorktree, [worktreeId]: tabId } + changed = true + } + } + return changed +} + export class Store { private state: PersistedState private readonly dataFile: string @@ -3938,6 +4002,14 @@ export class Store { migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache } + // Why: earlier builds wrote SSH pane membership into `ssh:` partitions; this build's + // binding writer is local-only and its reattach refuses to create, so an unhoisted partition + // means every pane is refused and the user's tabs are discarded. Runs on the final state so a + // later migration cannot replace the session object out from under it. + if (hoistSshPartitionsIntoLocalSession(migrated)) { + this.loadNeedsSave = true + } + logPersistenceStartupMilestone('persistence-load-done', { repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) diff --git a/src/main/ssh-partition-upgrade-tab-loss.test.ts b/src/main/ssh-partition-upgrade-tab-loss.test.ts index 879092d640d..521e7bcb025 100644 --- a/src/main/ssh-partition-upgrade-tab-loss.test.ts +++ b/src/main/ssh-partition-upgrade-tab-loss.test.ts @@ -44,6 +44,15 @@ async function createStore(state: Record = {}) { return new Store() } +/** Reopen the SAME profile — the upgrade launch. The hoist migration runs at load, so a test that + * writes the partition into an already-loaded store would never exercise it. */ +async function reopenStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + /** One SSH pane, exactly as the partitioned build persisted it. */ function paneSession() { return { @@ -71,14 +80,18 @@ describe('an SSH pane whose membership was persisted into the ssh partition', () }) it('is still bound on reattach after upgrading to a build that reads local', async () => { - // Only the ssh partition holds the pane — the shape an upgrading user actually carries. - // Written through the store, the way the partitioned build wrote it. - const store = await createStore() - store.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + // The old build: pane membership written into the ssh partition, nothing in local. + const before = await createStore() + before.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + expect( + before.getWorkspaceSession(SSH_PARTITION).tabsByWorktree?.[WORKTREE], + 'the ssh partition never received the pane, so this proves nothing' + ).toHaveLength(1) + expect(before.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).toHaveLength(0) + before.flushOrThrow() - // Anti-vacuity: the pane really is in the ssh partition and really is absent from local. - expect(store.getWorkspaceSession(SSH_PARTITION).tabsByWorktree?.[WORKTREE]).toHaveLength(1) - expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).toHaveLength(0) + // The upgrade launch. + const store = await reopenStore() // The reattach bind: no hostId, refuses to create. const bound = store.persistPtyBinding({ @@ -99,8 +112,10 @@ describe('an SSH pane whose membership was persisted into the ssh partition', () // `mayCreate` existed, the same state re-created the tab instead of discarding it. That is what // "before, many of the tabs were retained" described — they were being recreated. it('was recreated rather than discarded before the refusal was introduced', async () => { - const store = await createStore() - store.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + const before = await createStore() + before.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + before.flushOrThrow() + const store = await reopenStore() const bound = store.persistPtyBinding({ worktreeId: WORKTREE,