fix(persistence): fence duplicate lease rollback

This commit is contained in:
Neil
2026-08-12 20:30:08 -07:00
parent 2d8f09d137
commit 21ad47c0bc
2 changed files with 70 additions and 3 deletions
+13 -3
View File
@@ -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)
@@ -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<void> },
'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<void> },
'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: [