From 21ad47c0bc5883f0ae92e38120dd673cc3a4f570 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:30:08 -0700 Subject: [PATCH] fix(persistence): fence duplicate lease rollback --- src/main/persistence.ts | 16 +++++- .../ssh-reattach-pane-cardinality.test.ts | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0ef75710170..b27d9157fd8 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -7353,10 +7353,16 @@ export class Store { lease.worktreeId === winner.worktreeId && // Leaf only: a lease freezes its tabId, and a pane broken out to a new tab would otherwise // never compete with its own predecessor — which is the reported cardinality growth. - lease.leafId === winner.leafId && - lease.state !== 'terminated' && - lease.state !== 'expired' + lease.leafId === winner.leafId ) { + if (lease.state === 'expired') { + // A concurrent same-pane owner confirms this lease must outlive rollback as retired. + this.advanceSshRemotePtyLeaseMutationVersion(lease) + continue + } + if (lease.state === 'terminated') { + continue + } lease.state = 'expired' lease.updatedAt = now this.advanceSshRemotePtyLeaseMutationVersion(lease) @@ -7678,6 +7684,10 @@ export class Store { continue } if (state === 'detached' && lease.state !== 'attached') { + if (lease.state === 'expired') { + // A later detach fences rollback from restoring attached ownership. + this.advanceSshRemotePtyLeaseMutationVersion(lease) + } continue } this.advanceSshRemotePtyLeaseMutationVersion(lease) diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 52b370d6988..8f5665981c4 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -315,6 +315,63 @@ describe('STA-3077: existing duplicate leases are healed, not revived', () => { ).toBe('attached') }) + it('does not revive a provisional loser after a concurrent same-pane upsert', async () => { + const store = await createStore({ + sshRemotePtyLeases: [ + { ...leaseFor('relay-pty-a', 1), createdAt: 1 }, + { ...leaseFor('relay-pty-b', 2), createdAt: 2 } + ] + }) + let rejectWrite: (error: Error) => void = () => {} + vi.spyOn( + store as unknown as { flushDurableStateOrThrowAsync: () => Promise }, + 'flushDurableStateOrThrowAsync' + ).mockImplementation( + () => + new Promise((_, reject) => { + rejectWrite = reject + }) + ) + + const retirement = store.supersedeDuplicatePaneLeases(TARGET) + store.upsertSshRemotePtyLease({ ...leaseFor('relay-pty-c', 3), createdAt: 3 }) + rejectWrite(new Error('disk full')) + + expect(await retirement).toBe(0) + expect(liveLeasesForPane(store).map((lease) => lease.ptyId)).toEqual(['relay-pty-c']) + }) + + it('does not restore attached state over a concurrent detach', async () => { + const store = await createStore({ + sshRemotePtyLeases: [ + { ...leaseFor('relay-pty-a', 1), createdAt: 1 }, + { ...leaseFor('relay-pty-b', 2), createdAt: 2 } + ] + }) + let rejectWrite: (error: Error) => void = () => {} + vi.spyOn( + store as unknown as { flushDurableStateOrThrowAsync: () => Promise }, + 'flushDurableStateOrThrowAsync' + ).mockImplementation( + () => + new Promise((_, reject) => { + rejectWrite = reject + }) + ) + + const retirement = store.supersedeDuplicatePaneLeases(TARGET) + store.markSshRemotePtyLeasesForShutdown(TARGET, 'detached') + rejectWrite(new Error('disk full')) + + expect(await retirement).toBe(0) + expect( + store.getSshRemotePtyLeases(TARGET).find((lease) => lease.ptyId === 'relay-pty-a')?.state + ).toBe('expired') + expect( + store.getSshRemotePtyLeases(TARGET).find((lease) => lease.ptyId === 'relay-pty-b')?.state + ).toBe('detached') + }) + it('does not roll back concurrent session or lease changes after a failed write', async () => { const store = await createStore({ sshRemotePtyLeases: [