fix(remote): the first renderer publication after a restart drops a durably closed paired tab (#29433)

This commit is contained in:
Neil
2026-10-15 23:22:50 -07:00
committed by GitHub
parent f2fd97c16e
commit 8570b0a7d3
3 changed files with 83 additions and 23 deletions
@@ -95,7 +95,7 @@ function makeDeferred() {
return { promise, resolve }
}
function createHarness() {
function createHarness(options: { restarted?: boolean } = {}) {
let session = makeSession([TAB_A, TAB_B])
const repo = {
id: REPO_ID,
@@ -196,7 +196,10 @@ function createHarness() {
runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set<string> }
).pairedRendererSessionOwnedPtyIds.add(spec.ptyId)
}
publishRendererSnapshot([TAB_A, TAB_B], 1)
// Why: a restarted host serves its disk-hydrated snapshot until the renderer's first publication.
if (!options.restarted) {
publishRendererSnapshot([TAB_A, TAB_B], 1)
}
return {
runtime,
@@ -454,6 +457,29 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
])
})
it('red: the first renderer publication after a restart must not resurrect a retired tab', async () => {
const harness = createHarness({ restarted: true })
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort())
harness.retirePersistedTab(TAB_A)
harness.publishRendererSnapshot([TAB_B], 1)
expect(
await listParentTabIds(harness.runtime),
'the first post-restart merge resurrected a tab the host renderer closed'
).toEqual([TAB_B])
expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(false)
expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true)
})
it('control: the first post-restart publication keeps a still-persisted tab it omits', async () => {
const harness = createHarness({ restarted: true })
harness.publishRendererSnapshot([TAB_B], 1)
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort())
expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true)
})
it('control: a create still in flight is not retirement — its tab and ownership survive the omission', async () => {
const harness = createHarness()
@@ -108,22 +108,21 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
return next
}
protected releaseRuntimeSessionOwnershipForRendererRetiredTabs(
// Why: omitted from the renderer's publication AND from persistence = durably closed; a create in flight is not.
protected listRendererRetiredTerminalTabs(
incoming: RuntimeMobileSessionTabsSnapshot,
existing: RuntimeMobileSessionTabsSnapshot | undefined
): void {
if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) {
return
}
existing: RuntimeMobileSessionTabsSnapshot
): { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] {
const session = this.getWorkspaceSessionForWorktree(existing.worktree)
const persistedTabs = session?.tabsByWorktree?.[existing.worktree]
if (!session || !persistedTabs) {
return
return []
}
const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab]))
const incomingIds = new Set(
incoming.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab))
)
const retired: { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] = []
for (const tab of existing.tabs) {
if (
tab.type !== 'terminal' ||
@@ -132,18 +131,12 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
) {
continue
}
if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) {
continue
}
const persistedParent = persistedTabsById.get(tab.parentTabId)
if (!persistedParent) {
this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId)
retired.push({ tab, scope: 'tab' })
continue
}
const layout = session.terminalLayoutsByTabId?.[tab.parentTabId]
const boundPtyIds = [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter(
(id): id is string => typeof id === 'string'
)
const persistedIds = new Set(
[persistedParent.ptyId, ...Object.values(layout?.ptyIdsByLeafId ?? {})].filter(
(id): id is string => typeof id === 'string'
@@ -153,14 +146,36 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
layout &&
!layout.ptyIdsByLeafId?.[tab.leafId] &&
!terminalLayoutContainsLeaf(layout.root, tab.leafId) &&
!boundPtyIds.some((id) => persistedIds.has(id))
!getMobileTerminalLeafPtyIds(tab).some((id) => persistedIds.has(id))
) {
for (const ptyId of boundPtyIds) {
const pty = this.ptysById.get(ptyId)
if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) {
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(ptyId, false)
}
retired.push({ tab, scope: 'leaf' })
}
}
return retired
}
protected releaseRuntimeSessionOwnershipForRendererRetiredTabs(
incoming: RuntimeMobileSessionTabsSnapshot,
existing: RuntimeMobileSessionTabsSnapshot | undefined
): void {
// Why no headless-built exemption: the first renderer publication after a restart lands on a
// disk-hydrated base, and skipping it left a retired paired tab owned until the next sync (STA-4897).
if (!existing) {
return
}
for (const { tab, scope } of this.listRendererRetiredTerminalTabs(incoming, existing)) {
if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) {
continue
}
if (scope === 'tab') {
this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId)
continue
}
for (const ptyId of getMobileTerminalLeafPtyIds(tab)) {
const pty = this.ptysById.get(ptyId)
if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) {
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(ptyId, false)
}
}
}
@@ -199,3 +214,9 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
}
}
}
function getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] {
return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter(
(id): id is string => typeof id === 'string'
)
}
@@ -44,10 +44,23 @@ export abstract class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab ex
const incomingIds = new Set(
incoming?.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab)) ?? []
)
// Why: a headless-built base preserves every terminal, so a renderer publication over it
// (the first after a restart) must still drop the tabs the renderer durably closed (STA-4897).
const retiredTabIds =
incoming && this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)
? new Set(this.listRendererRetiredTerminalTabs(incoming, existing).map(({ tab }) => tab.id))
: null
return existing.tabs.filter((tab) => {
if (getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) {
return false
}
if (
tab.type === 'terminal' &&
retiredTabIds?.has(tab.id) &&
!this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(existing.worktree, tab)
) {
return false
}
return this.shouldPreserveHeadlessMobileSessionTab(existing, tab)
})
}