fix(ssh): roll back a lease retirement whose durable write fails

`flush()` logs and swallows write errors, so a failed write left these
leases retired in memory while disk still called them attached — and the
pane bindings scrubbed alongside them stayed scrubbed. Use `flushOrThrow`
and restore both the lease states and the affected session partitions
when it throws, reporting nothing retired.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-08 19:11:34 -07:00
co-authored by Orca
parent ccb082740f
commit 8d9aeb55e0
2 changed files with 64 additions and 1 deletions
+47 -1
View File
@@ -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()
@@ -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({