diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 58b5a393541..9517e6ba9f1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2908,7 +2908,14 @@ export function hoistSshPartitionsIntoLocalSession( if (missing.length === 0) { continue } - local.tabsByWorktree = { ...local.tabsByWorktree, [worktreeId]: [...existing, ...missing] } + // Cloned, not aliased. Spreading the partition's own tab objects in leaves both planes + // holding the SAME object, so a mutation scoped to `local` — the whole point of + // `arbitratedFrom` — silently rewrites the partition too, stranding a shell whose owner + // never voted on that decision. + local.tabsByWorktree = { + ...local.tabsByWorktree, + [worktreeId]: [...existing, ...missing.map((tab) => structuredClone(tab))] + } for (const tab of missing) { alreadyHoisted.add(tab.id) } @@ -2925,7 +2932,11 @@ export function hoistSshPartitionsIntoLocalSession( if (!hasTab) { continue } - local.terminalLayoutsByTabId = { ...local.terminalLayoutsByTabId, [tabId]: layout } + // Cloned for the same reason: a shared layout leaks `ptyIdsByLeafId` edits across planes. + local.terminalLayoutsByTabId = { + ...local.terminalLayoutsByTabId, + [tabId]: structuredClone(layout) + } changed = true } for (const [paneKey, incarnationId] of Object.entries( diff --git a/src/main/ssh-partition-upgrade-tab-loss.test.ts b/src/main/ssh-partition-upgrade-tab-loss.test.ts index 9c0f5ed2427..c98a240bc60 100644 --- a/src/main/ssh-partition-upgrade-tab-loss.test.ts +++ b/src/main/ssh-partition-upgrade-tab-loss.test.ts @@ -373,4 +373,26 @@ describe('an SSH pane whose membership was persisted into the ssh partition', () ) }) }) + + // The hoisted pane must not ALIAS the partition's objects. `arbitratedFrom: 'local'` exists so a + // decision read from one plane cannot mutate the other; a shared object defeats it silently and + // strands a shell whose owner never voted on that decision. + it('hoists copies, so mutating the local pane leaves the partition alone', async () => { + const before = await createStore() + before.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + before.flushOrThrow() + stripHoistMarker() + + const store = await reopenStore() + const localTab = (store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? [])[0] + expect(localTab, 'the pane was not hoisted, so aliasing cannot be observed').toBeDefined() + + // A mutation scoped to the local plane. + localTab!.ptyId = null + + expect( + store.getWorkspaceSession(SSH_PARTITION).tabsByWorktree?.[WORKTREE]?.[0]?.ptyId, + 'a local-only edit reached through into the ssh partition' + ).toBe(PTY) + }) })