fix(ssh): fold the pane incarnation with the binding it fences

Found by adversarial review. `persistPtyBinding` writes the binding and its
incarnation into the SAME partition, and the incarnation is what its CAS
compares. Step P's load fold moved only `ptyIdsByLeafId`, so after upgrade a
pane whose incarnation had been written to `ssh:<target>` kept the guard in the
partition nothing reads: `resolvePersistedStablePaneOwner` read `undefined` from
`local`, the CAS then compared undefined against undefined, and the incarnation
half of the fence passed for any value until the next write healed it.

Not data loss and not a wrong-shell bind — the ptyId half still held — but a
guard silently weakened by a migration is the exact shape this program keeps
finding, so it is closed rather than noted.

Mutation proof: disabling the incarnation half of the fold reddens the new
clause; the other eight stay green.

One clause in persistence.test.ts asserted the incarnation survives a reload in
the SSH partition. Its subject is that the reconciled value is preserved, not
which partition holds it, so the assertion follows the binding to its one home
and additionally pins that the ssh partition no longer keeps a copy.
This commit is contained in:
Neil
2026-08-09 02:39:57 -07:00
parent 22a898e2d6
commit 994733d8b1
3 changed files with 50 additions and 3 deletions
+14 -3
View File
@@ -9258,10 +9258,21 @@ describe('Store', () => {
expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-live'
)
// The in-session assertion above is unchanged. Across a RELOAD the SSH case now resolves in
// the local partition: STA-3077 step P gave an SSH pane binding one home, and the load fold
// carries the incarnation with the binding it fences — leaving it behind would strand the CAS
// guard in a partition no reader consults. The preserved value is what this pins, not which
// partition holds it, so the assertion follows the binding.
const reloaded = await createStore()
expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
'inc-live'
)
const foldedHostId = hostId?.startsWith('ssh:') ? undefined : hostId
expect(
reloaded.getWorkspaceSession(foldedHostId).terminalPtyIncarnationsByPaneKey?.[paneKey]
).toBe('inc-live')
if (hostId?.startsWith('ssh:')) {
expect(
reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]
).toBeUndefined()
}
}
)
+12
View File
@@ -7290,6 +7290,18 @@ export class Store {
layout.ptyIdsByLeafId = {}
changed = true
}
// The incarnation is the other half of the same fence — `persistPtyBinding` writes it into
// whichever partition it wrote the binding to, so leaving it behind would move the binding
// while its CAS guard stayed in the partition nothing reads.
const incarnations = host?.terminalPtyIncarnationsByPaneKey
if (incarnations && Object.keys(incarnations).length > 0) {
local.terminalPtyIncarnationsByPaneKey = {
...incarnations,
...local.terminalPtyIncarnationsByPaneKey
}
host.terminalPtyIncarnationsByPaneKey = {}
changed = true
}
}
return changed
}
@@ -251,6 +251,30 @@ describe('STA-3077 step P: the pane binding has one home', () => {
})
})
describe('STA-3077 step P: the fold carries the whole fence', () => {
// `persistPtyBinding` writes the binding and its incarnation into the SAME partition, and the
// incarnation is what its CAS compares. Folding only the binding would move the guard's subject
// to `local` while the guard itself stayed in the partition nothing reads — the CAS would then
// compare undefined against undefined and pass for any incarnation.
it('folds the pane incarnation along with the binding it fences', async () => {
const sshSession = paneSession('pty-1') as unknown as {
terminalPtyIncarnationsByPaneKey: Record<string, string>
}
sshSession.terminalPtyIncarnationsByPaneKey = { [`${TAB}:${LEAF}`]: 'inc-pty-1' }
const store = await createStore({
workspaceSession: paneSession('pty-1'),
workspaceSessionsByHostId: { [SSH_PARTITION]: sshSession }
})
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[`${TAB}:${LEAF}`]).toBe(
'inc-pty-1'
)
expect(
store.getWorkspaceSession(SSH_PARTITION).terminalPtyIncarnationsByPaneKey?.[`${TAB}:${LEAF}`]
).toBeUndefined()
})
})
describe('STA-3077 step P: every production caller names that one home', () => {
const summarize = (source: string): string => source.replace(/\s+/g, ' ').trim().slice(0, 90)