diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 98c5bda8e28..b5763cdc94e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -7326,10 +7326,16 @@ export class Store { const winners = new Set(winnerByPane.values()) const now = Date.now() const superseded: SshRemotePtyLease[] = [] + const restore: (() => void)[] = [] for (const lease of live) { if (!lease.worktreeId || !lease.tabId || !lease.leafId || winners.has(lease)) { continue } + const { state, updatedAt } = lease + restore.push(() => { + lease.state = state + lease.updatedAt = updatedAt + }) lease.state = 'expired' lease.updatedAt = now superseded.push(lease) @@ -7337,11 +7343,51 @@ export class Store { if (superseded.length === 0) { return 0 } + const sessionsBefore = this.cloneSshLeaseBindingSessions(targetId) this.clearSshRemotePtyBindingsForLeases(targetId, superseded) - this.flush() + try { + // Why flushOrThrow: flush() swallows write errors, which would leave these + // leases retired in memory but attached on disk for the rest of the session. + this.flushOrThrow() + } catch (err) { + for (const undo of restore) { + undo() + } + this.restoreSshLeaseBindingSessions(targetId, sessionsBefore) + console.error('[persistence] Failed to retire duplicate pane leases:', err) + return 0 + } return superseded.length } + private cloneSshLeaseBindingSessions(targetId: string): { + local?: WorkspaceSessionState + host?: WorkspaceSessionState + } { + const host = this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] + return { + ...(this.state.workspaceSession + ? { local: cloneWorkspaceSessionState(this.state.workspaceSession) } + : {}), + ...(host ? { host: cloneWorkspaceSessionState(host) } : {}) + } + } + + private restoreSshLeaseBindingSessions( + targetId: string, + sessions: { local?: WorkspaceSessionState; host?: WorkspaceSessionState } + ): void { + if (sessions.local) { + this.state.workspaceSession = sessions.local + } + if (sessions.host) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [toSshExecutionHostId(targetId)]: sessions.host + } + } + } + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { if (this.updateSshRemotePtyLeaseStates(targetId, state)) { this.flush() diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 2ea3cb10b49..521dc918076 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -187,6 +187,23 @@ describe('STA-3077: existing duplicate leases are healed, not revived', () => { expect(liveLeasesForPane(store).map((lease) => lease.ptyId)).toEqual(['relay-pty-bound']) }) + // A retirement that is not durable must not be believed: it would read as + // retired in memory and attached on disk for the rest of the session. + it('rolls the retirement back when the durable write fails', async () => { + const store = await createStore({ + sshRemotePtyLeases: [ + { ...leaseFor('relay-pty-a', 1), createdAt: 1 }, + { ...leaseFor('relay-pty-b', 2), createdAt: 2 } + ] + }) + vi.spyOn(store, 'flushOrThrow').mockImplementation(() => { + throw new Error('disk full') + }) + + expect(store.supersedeDuplicatePaneLeases(TARGET)).toBe(0) + expect(liveLeasesForPane(store)).toHaveLength(2) + }) + it('leaves distinct panes alone', async () => { const otherLeaf = '8a2b4c6d-1e3f-4a5b-8c7d-9e0f1a2b3c4d' const store = await createStore({