mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
fix(mobile): paired clients re-derive a kept terminal after a cold restore (#23109)
* fix(mobile): paired clients re-derive a kept terminal after a cold restore A renderer frame published before a cold-restored terminal's PTY registered was fenced to an empty tab list and recorded as accepted, and the renderer never resends unchanged content. When registerPty binds a surface the accepted frame fenced out, re-merge that frame so the fence reads current state. * test(mobile): drive the live desktop window through the runtime's desktop seam * test(mobile): the re-derive path never flushes the store synchronously * test(mobile): a re-derived frame must not bring back a surface the host retired after accept * fix(mobile): a re-derived frame changes membership only for the registering surface The replay re-ran the whole accepted frame, so a surface the host retired after accept (a phone close whose remote PTY is still exiting, or a closed chat tab) came back. Every other surface now keeps the host's current decision; the removal repair is extracted from the terminal retirement helper so non-terminal tabs are removed the same way. * test(mobile): a re-derived frame must not drop or disown a phone-created terminal the desktop has not published * fix(mobile): a re-derived frame does not infer renderer retirements from its older frame * revert(mobile): drop the replay of a fenced renderer frame Reverts the production parts ofa88e1eaa0a,f5b99d0003and5af1c6d97a: the kept renderer frame, rederiveFencedRendererSurface and its registerPty call, and the mergeRendererMobileSnapshot / removeMobileSessionSnapshotTabs extractions. The fence will instead read the host's saved membership record. The test file stays and is rewritten for that mechanism. * fix(mobile): the paired-list fence admits a terminal the saved session still lists After a cold restore the in-memory mobile snapshot and PTY table start empty, so in a repo with host-authoritative terminal membership the fence dropped a restored terminal whose renderer frame arrived before its PTY registered, and paired clients never listed it. The fence now also admits a surface the host's saved workspace session still lists (tab under the worktree, leaf in its layout), and registerPty pushes the listing so pending-handle turns ready at once. A restored pane whose PTY never returns is listed as pending-handle, as in repos that are not host-authoritative. * test(mobile): keep the desktop window stub's type assertion on its SAFETY line * fix(mobile): coalesce the registration push for a listed restored terminal registerPty pushed the paired list immediately on every registration that backs a listed surface. The desktop's graph sync after a spawn already publishes the same pending-handle to ready flip on the 50 ms coalescing window, so each restored pane cost two pushes, and a restore of N panes cost N immediate full-list pushes per client. The touch now rides the same coalescing window, which still covers a registration no graph change follows. The test's "unchanged" sync dropped the graph's tab, which is itself a change, and its no-extra-push assertion ran before any coalesced push could fire; both are fixed, and a restore of two panes is asserted to push once. The fence comment no longer claims the new clause keeps pending leaves out of the graph: once the surface is listed, its leaves pass the shared predicate through that listing, as any listed surface's do. * fix(mobile): read saved membership only from the worktree's own session partition For a runtime-host workspace, emptying the owning partition re-routes session reads to a single other partition that still lists the worktree. If that older copy lists a surface a retirement just removed, a lagging renderer frame could re-admit it. The saved-membership check now reads only the partition the worktree's host names, so it never trusts a fallback copy. * test(mobile): pin the own saved partition for every workspace host kind Also correct the immediate-emit comment: only an exit bypasses the window; a registration's ready flip coalesces.
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type {
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { setRuntimeDesktopSurface } from './runtime-desktop-surface'
|
||||
import { withDurableRuntimeStore } from './runtime-durable-store-fixture'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const REPO_ID = 'repo'
|
||||
const LEFT = '11111111-1111-4111-8111-111111111111'
|
||||
const RIGHT = '22222222-2222-4222-8222-222222222222'
|
||||
const SPLIT_ROOT = {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
first: { type: 'leaf' as const, leafId: LEFT },
|
||||
second: { type: 'leaf' as const, leafId: RIGHT }
|
||||
}
|
||||
|
||||
type RestoreHost = {
|
||||
repo: {
|
||||
id: string
|
||||
path: string
|
||||
displayName: string
|
||||
badgeColor: string
|
||||
addedAt: number
|
||||
connectionId?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
hostId: ExecutionHostId
|
||||
connectionId: string | null
|
||||
ptyIds: { left: string; right: string }
|
||||
/** An SSH relaunch has not bound its PTY into the saved session yet. */
|
||||
persistsPtyBindings: boolean
|
||||
}
|
||||
|
||||
const LOCAL_HOST: RestoreHost = {
|
||||
repo: { id: REPO_ID, path: '/worktree', displayName: 'repo', badgeColor: 'blue', addedAt: 1 },
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
connectionId: null,
|
||||
ptyIds: { left: 'pty-left', right: 'pty-right' },
|
||||
persistsPtyBindings: true
|
||||
}
|
||||
const SSH_HOST: RestoreHost = {
|
||||
repo: { ...LOCAL_HOST.repo, connectionId: 'ssh-1' },
|
||||
hostId: 'ssh:ssh-1',
|
||||
connectionId: 'ssh-1',
|
||||
ptyIds: { left: 'ssh:ssh-1@@pty-left', right: 'ssh:ssh-1@@pty-right' },
|
||||
persistsPtyBindings: false
|
||||
}
|
||||
const RUNTIME_HOST: RestoreHost = {
|
||||
...LOCAL_HOST,
|
||||
repo: { ...LOCAL_HOST.repo, executionHostId: 'runtime:env-1' },
|
||||
hostId: 'runtime:env-1'
|
||||
}
|
||||
|
||||
// A cold restore: the saved split survives, and an earlier incarnation change left the repo's
|
||||
// terminal membership host-authoritative, but no PTY has registered yet.
|
||||
function makeColdRestoredSession(host: RestoreHost): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'tab',
|
||||
ptyId: host.persistsPtyBindings ? host.ptyIds.left : null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab: {
|
||||
root: SPLIT_ROOT,
|
||||
activeLeafId: LEFT,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: host.persistsPtyBindings
|
||||
? { [LEFT]: host.ptyIds.left, [RIGHT]: host.ptyIds.right }
|
||||
: {}
|
||||
}
|
||||
},
|
||||
terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 }
|
||||
}
|
||||
}
|
||||
|
||||
function makeRendererFrame(
|
||||
host: RestoreHost,
|
||||
options: {
|
||||
version?: number
|
||||
leaves?: readonly string[]
|
||||
extraTabs?: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
} = {}
|
||||
): RuntimeMobileSessionTabsSnapshot {
|
||||
const leaves = options.leaves ?? [LEFT, RIGHT]
|
||||
const ptyIdByLeaf: Record<string, string> = {
|
||||
[LEFT]: host.ptyIds.left,
|
||||
[RIGHT]: host.ptyIds.right
|
||||
}
|
||||
const parentLayout = {
|
||||
root: leaves.length === 2 ? SPLIT_ROOT : { type: 'leaf' as const, leafId: leaves[0] },
|
||||
activeLeafId: leaves[0],
|
||||
expandedLeafId: leaves[0],
|
||||
ptyIdsByLeafId: Object.fromEntries(leaves.map((leafId) => [leafId, ptyIdByLeaf[leafId]]))
|
||||
}
|
||||
const extraTabs = options.extraTabs ?? []
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: options.version ?? 1,
|
||||
activeGroupId: 'group',
|
||||
activeTabId: `tab::${leaves[0]}`,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{ id: 'group', activeTabId: 'tab', tabOrder: ['tab', ...extraTabs.map((tab) => tab.id)] }
|
||||
],
|
||||
tabs: [
|
||||
...leaves.map((leafId, index) => ({
|
||||
type: 'terminal' as const,
|
||||
id: `tab::${leafId}`,
|
||||
parentTabId: 'tab',
|
||||
leafId,
|
||||
ptyId: ptyIdByLeaf[leafId],
|
||||
title: leafId === LEFT ? 'Left' : 'Right',
|
||||
parentLayout,
|
||||
isActive: index === 0
|
||||
})),
|
||||
...extraTabs
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const RENDERER_TABS = [
|
||||
{
|
||||
tabId: 'tab',
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
activeLeafId: LEFT,
|
||||
layout: SPLIT_ROOT
|
||||
}
|
||||
]
|
||||
|
||||
function publishRendererFrame(
|
||||
runtime: OrcaRuntimeService,
|
||||
frame: RuntimeMobileSessionTabsSnapshot
|
||||
): ReturnType<OrcaRuntimeService['syncWindowGraph']> {
|
||||
return runtime.syncWindowGraph(1, { tabs: RENDERER_TABS, leaves: [], mobileSessionTabs: [frame] })
|
||||
}
|
||||
|
||||
function coldRestoredRuntime(host: RestoreHost = LOCAL_HOST): {
|
||||
runtime: OrcaRuntimeService
|
||||
sessions: Map<ExecutionHostId, WorkspaceSessionState>
|
||||
} {
|
||||
const sessions = new Map<ExecutionHostId, WorkspaceSessionState>([
|
||||
[host.hostId, makeColdRestoredSession(host)]
|
||||
])
|
||||
// The desktop window is live, so main must not rebuild the list from the saved session.
|
||||
const liveWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: () => {} }
|
||||
}
|
||||
setRuntimeDesktopSurface({
|
||||
showNotification: () => false,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only liveness and send off its authoritative window on these paths.
|
||||
findWindowById: () => liveWindow as never,
|
||||
onIpc: () => {},
|
||||
removeIpcListener: () => {}
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list, fence, exit and close paths read only repos and the workspace session; the rest of Store is unreached.
|
||||
withDurableRuntimeStore({
|
||||
getRepos: () => [host.repo],
|
||||
getRepo: (id: string) => (id === REPO_ID ? host.repo : undefined),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
|
||||
getProjects: () => [],
|
||||
getWorkspaceSessionHostIds: () => [...sessions.keys()],
|
||||
getWorkspaceSession: (hostId?: ExecutionHostId) =>
|
||||
sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: (next: WorkspaceSessionState, hostId?: ExecutionHostId) => {
|
||||
sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, next)
|
||||
},
|
||||
flushPendingOrThrowAsync: async () => {},
|
||||
// The production store commits asynchronously; a synchronous flush on these paths is a bug.
|
||||
flushOrThrow: () => {
|
||||
throw new Error('synchronous flush')
|
||||
}
|
||||
}) as never
|
||||
)
|
||||
runtime.attachWindow(1)
|
||||
return { runtime, sessions }
|
||||
}
|
||||
|
||||
function registerLeaf(runtime: OrcaRuntimeService, host: RestoreHost, leaf: 'left' | 'right') {
|
||||
runtime.registerPty(host.ptyIds[leaf], WORKTREE_ID, host.connectionId, {
|
||||
tabId: 'tab',
|
||||
leafId: leaf === 'left' ? LEFT : RIGHT,
|
||||
incarnationId: `incarnation-${leaf}`
|
||||
})
|
||||
}
|
||||
|
||||
function surfaces(result: RuntimeMobileSessionTabsResult | undefined): string[] {
|
||||
return (result?.tabs ?? []).map((tab) => `${tab.id}:${'status' in tab ? tab.status : ''}`)
|
||||
}
|
||||
|
||||
async function listedSurfaces(runtime: OrcaRuntimeService): Promise<string[]> {
|
||||
return surfaces(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`))
|
||||
}
|
||||
|
||||
// The session-tabs notify coalescer's max wait (mobile-session-tabs-notify-coalescer.ts).
|
||||
const COALESCED_PUSH_MS = 250
|
||||
|
||||
describe('a restored terminal published before its PTY registers', () => {
|
||||
afterEach(() => {
|
||||
setRuntimeDesktopSurface(null)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['local', LOCAL_HOST],
|
||||
['SSH without a saved PTY binding', SSH_HOST],
|
||||
['runtime host', RUNTIME_HOST]
|
||||
])('%s: is listed pending, then pushed ready when its PTY registers', async (_label, host) => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime } = coldRestoredRuntime(host)
|
||||
publishRendererFrame(runtime, makeRendererFrame(host))
|
||||
expect(await listedSurfaces(runtime)).toEqual([
|
||||
`tab::${LEFT}:pending-handle`,
|
||||
`tab::${RIGHT}:pending-handle`
|
||||
])
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
const published: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event))
|
||||
|
||||
registerLeaf(runtime, host, 'left')
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
|
||||
// Registration alone reaches clients: no renderer frame or graph change follows it here.
|
||||
expect(surfaces(published.at(-1))).toEqual([
|
||||
`tab::${LEFT}:ready`,
|
||||
`tab::${RIGHT}:pending-handle`
|
||||
])
|
||||
const pushes = published.length
|
||||
expect(
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: RENDERER_TABS,
|
||||
leaves: [],
|
||||
mobileSessionTabs: [],
|
||||
unchangedMobileSessionWorktrees: [WORKTREE_ID]
|
||||
}).mobileSessionResyncWorktrees
|
||||
).toBeUndefined()
|
||||
publishRendererFrame(runtime, makeRendererFrame(host))
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
expect(published).toHaveLength(pushes)
|
||||
expect(await listedSurfaces(runtime)).toEqual([
|
||||
`tab::${LEFT}:ready`,
|
||||
`tab::${RIGHT}:pending-handle`
|
||||
])
|
||||
|
||||
registerLeaf(runtime, host, 'right')
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
|
||||
expect(surfaces(published.at(-1))).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('pushes a restore of several panes once, not once per registering pane', () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime } = coldRestoredRuntime()
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
const published: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event))
|
||||
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
registerLeaf(runtime, LOCAL_HOST, 'right')
|
||||
vi.advanceTimersByTime(COALESCED_PUSH_MS)
|
||||
|
||||
expect(published.map(surfaces)).toEqual([[`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('keeps listing a restored pane whose PTY never returns as pending', async () => {
|
||||
const { runtime } = coldRestoredRuntime()
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
|
||||
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
runtime.registerPty('pty-other', WORKTREE_ID, null, {
|
||||
tabId: 'tab-other',
|
||||
leafId: RIGHT,
|
||||
incarnationId: 'incarnation-other'
|
||||
})
|
||||
|
||||
expect(await listedSurfaces(runtime)).toEqual([
|
||||
`tab::${LEFT}:ready`,
|
||||
`tab::${RIGHT}:pending-handle`
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a retired restored terminal stays out of a later renderer frame', () => {
|
||||
afterEach(() => setRuntimeDesktopSurface(null))
|
||||
|
||||
it('after its process exits and the retirement is saved', async () => {
|
||||
const { runtime, sessions } = coldRestoredRuntime()
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
|
||||
expect(await listedSurfaces(runtime)).toContain(`tab::${LEFT}:ready`)
|
||||
|
||||
await runtime.onPtyExit('pty-left', 0, 'incarnation-left')
|
||||
|
||||
expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: RIGHT
|
||||
})
|
||||
// A lagging renderer still lists the exited pane, at a newer version.
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2 }))
|
||||
expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:ready`)
|
||||
expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:pending-handle`)
|
||||
})
|
||||
|
||||
it('after its last pane exits from a runtime-host partition an older copy still lists', async () => {
|
||||
const { runtime, sessions } = coldRestoredRuntime(RUNTIME_HOST)
|
||||
// Left behind in another partition before the catalog owner rotated.
|
||||
sessions.set(LOCAL_EXECUTION_HOST_ID, makeColdRestoredSession(RUNTIME_HOST))
|
||||
registerLeaf(runtime, RUNTIME_HOST, 'left')
|
||||
registerLeaf(runtime, RUNTIME_HOST, 'right')
|
||||
publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST))
|
||||
|
||||
await runtime.onPtyExit('pty-left', 0, 'incarnation-left')
|
||||
await runtime.onPtyExit('pty-right', 0, 'incarnation-right')
|
||||
// Emptying the owner's partition re-routes reads to the older copy.
|
||||
expect(sessions.get(RUNTIME_HOST.hostId)?.tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
|
||||
publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST, { version: 2 }))
|
||||
expect(await listedSurfaces(runtime)).toEqual([])
|
||||
})
|
||||
|
||||
it('after the user closes it on the desktop', async () => {
|
||||
const { runtime, sessions } = coldRestoredRuntime()
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
registerLeaf(runtime, LOCAL_HOST, 'right')
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
|
||||
expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`])
|
||||
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2, leaves: [LEFT] }))
|
||||
await runtime.onPtyExit('pty-right', 0, 'incarnation-right')
|
||||
expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: LEFT
|
||||
})
|
||||
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 3 }))
|
||||
expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`])
|
||||
})
|
||||
})
|
||||
|
||||
// An SSH worktree: the phone closes a tab while a relaunched sibling tab has not registered yet.
|
||||
const SSH_PTY_X = 'ssh:ssh-1@@pty-x'
|
||||
const SSH_PTY_Y = 'ssh:ssh-1@@pty-y'
|
||||
|
||||
function makeSshSession(tabIds: readonly ('tab-x' | 'tab-y')[]): WorkspaceSessionState {
|
||||
// tab-x's relaunched PTY has not bound yet, so only tab-y saves a relay binding.
|
||||
const specs = {
|
||||
'tab-x': { leafId: LEFT, ptyId: null },
|
||||
'tab-y': { leafId: RIGHT, ptyId: SSH_PTY_Y }
|
||||
}
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: tabIds.map((id, index) => ({
|
||||
id,
|
||||
ptyId: specs[id].ptyId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: index + 1
|
||||
}))
|
||||
},
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
tabIds.map((id) => [
|
||||
id,
|
||||
{
|
||||
root: { type: 'leaf' as const, leafId: specs[id].leafId },
|
||||
activeLeafId: specs[id].leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: specs[id].ptyId ? { [specs[id].leafId]: specs[id].ptyId } : {}
|
||||
}
|
||||
])
|
||||
),
|
||||
terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 }
|
||||
}
|
||||
}
|
||||
|
||||
function publishSshRendererFrame(runtime: OrcaRuntimeService): void {
|
||||
const tabs = [
|
||||
{ tabId: 'tab-x', leafId: LEFT, ptyId: SSH_PTY_X },
|
||||
{ tabId: 'tab-y', leafId: RIGHT, ptyId: SSH_PTY_Y }
|
||||
]
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: tabs.map(({ tabId, leafId }) => ({
|
||||
tabId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: tabId,
|
||||
activeLeafId: leafId,
|
||||
layout: { type: 'leaf' as const, leafId }
|
||||
})),
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group',
|
||||
activeTabId: `tab-y::${RIGHT}`,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [{ id: 'group', activeTabId: 'tab-y', tabOrder: ['tab-x', 'tab-y'] }],
|
||||
tabs: tabs.map(({ tabId, leafId, ptyId }) => ({
|
||||
type: 'terminal' as const,
|
||||
id: `${tabId}::${leafId}`,
|
||||
parentTabId: tabId,
|
||||
leafId,
|
||||
ptyId,
|
||||
title: tabId,
|
||||
isActive: tabId === 'tab-y'
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('a surface the host retired stays retired when a restored sibling registers', () => {
|
||||
afterEach(() => setRuntimeDesktopSurface(null))
|
||||
|
||||
it('keeps a phone-closed SSH terminal closed while its remote PTY is still exiting', async () => {
|
||||
const { runtime, sessions } = coldRestoredRuntime(SSH_HOST)
|
||||
sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x', 'tab-y']))
|
||||
const kill = vi.fn(() => true)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path uses only these two relays.
|
||||
runtime.setNotifier({
|
||||
closeTerminal: vi.fn(),
|
||||
// The renderer durably retires the tab and acks; its pruned frame is still in flight.
|
||||
closeTerminalTab: vi.fn(async () => {
|
||||
sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x']))
|
||||
})
|
||||
} as never)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path kills and inventories only; the remote kill lands asynchronously.
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
listProcesses: vi.fn(async () =>
|
||||
[SSH_PTY_X, SSH_PTY_Y].map((id) => ({ id, cwd: '/worktree', title: 'shell' }))
|
||||
),
|
||||
getForegroundProcess: async () => null
|
||||
} as never)
|
||||
publishSshRendererFrame(runtime)
|
||||
runtime.registerPty(SSH_PTY_Y, WORKTREE_ID, 'ssh-1', {
|
||||
tabId: 'tab-y',
|
||||
leafId: RIGHT,
|
||||
incarnationId: 'incarnation-y'
|
||||
})
|
||||
expect(await listedSurfaces(runtime)).toContain(`tab-y::${RIGHT}:ready`)
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'tab-y', { reason: 'user' })
|
||||
expect(kill).toHaveBeenCalledWith(SSH_PTY_Y)
|
||||
runtime.registerPty(SSH_PTY_X, WORKTREE_ID, 'ssh-1', {
|
||||
tabId: 'tab-x',
|
||||
leafId: LEFT,
|
||||
incarnationId: 'incarnation-x'
|
||||
})
|
||||
|
||||
expect(await listedSurfaces(runtime)).not.toContain(`tab-y::${RIGHT}:ready`)
|
||||
})
|
||||
|
||||
it('keeps a phone-closed chat tab closed', async () => {
|
||||
const { runtime } = coldRestoredRuntime()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path uses only this relay.
|
||||
runtime.setNotifier({ closeSessionTab: vi.fn(async () => {}) } as never)
|
||||
publishRendererFrame(
|
||||
runtime,
|
||||
makeRendererFrame(LOCAL_HOST, {
|
||||
extraTabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'chat',
|
||||
title: 'Chat',
|
||||
sessionId: 'session-chat',
|
||||
agent: 'claude',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(await listedSurfaces(runtime)).toContain('chat:')
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'chat', { reason: 'user' })
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
|
||||
expect(await listedSurfaces(runtime)).not.toContain('chat:')
|
||||
})
|
||||
})
|
||||
|
||||
// A phone creates a terminal while the desktop's frame still lacks it: the spawn registered and the
|
||||
// host published the tab itself, as it does for a create the desktop has not published yet.
|
||||
const PHONE_LEAF = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
async function runtimeWithUnpublishedPhoneCreate(): Promise<OrcaRuntimeService> {
|
||||
const { runtime } = coldRestoredRuntime()
|
||||
const handlers = new Map<string, (event: unknown, reply: unknown) => void>()
|
||||
const webContents = {
|
||||
isDestroyed: () => false,
|
||||
setBackgroundThrottling: () => {},
|
||||
send: (channel: string, payload: { requestId: string }) => {
|
||||
if (channel !== 'terminal:requestTabCreate') {
|
||||
return
|
||||
}
|
||||
runtime.registerPty('pty-phone', WORKTREE_ID, null, {
|
||||
tabId: 'tab-phone',
|
||||
leafId: PHONE_LEAF,
|
||||
incarnationId: 'incarnation-phone'
|
||||
})
|
||||
handlers.get('terminal:tabCreateReply')?.(
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-phone', title: 'Terminal' }
|
||||
)
|
||||
}
|
||||
}
|
||||
setRuntimeDesktopSurface({
|
||||
showNotification: () => false,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path reads only liveness and these webContents members off its authoritative window.
|
||||
findWindowById: () => ({ isDestroyed: () => false, webContents }) as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the tab-create reply channel is registered on this path.
|
||||
onIpc: (channel, listener) => handlers.set(channel, listener as never),
|
||||
removeIpcListener: (channel) => handlers.delete(channel)
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path focuses only when activating, which this create does not.
|
||||
runtime.setNotifier({ focusTerminal: vi.fn() } as never)
|
||||
Object.assign(runtime, {
|
||||
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
|
||||
id: WORKTREE_ID,
|
||||
path: '/worktree',
|
||||
connectionId: null,
|
||||
repo: LOCAL_HOST.repo,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
})
|
||||
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
|
||||
await runtime.createMobileSessionTerminal(`id:${WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
clientNavigationId: 'phone'
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('a surface the host added stays listed when a restored sibling registers', () => {
|
||||
afterEach(() => setRuntimeDesktopSurface(null))
|
||||
|
||||
it('keeps a phone-created terminal listed', async () => {
|
||||
const runtime = await runtimeWithUnpublishedPhoneCreate()
|
||||
expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`)
|
||||
|
||||
registerLeaf(runtime, LOCAL_HOST, 'left')
|
||||
|
||||
expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`)
|
||||
})
|
||||
})
|
||||
@@ -180,6 +180,10 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste
|
||||
return this.workspaceSessions.get(worktreeId)
|
||||
}
|
||||
|
||||
protected getOwnWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null {
|
||||
return this.workspaceSessions.getOwnPartition(worktreeId)
|
||||
}
|
||||
|
||||
protected setWorkspaceSessionForWorktree(
|
||||
worktreeId: string,
|
||||
session: WorkspaceSessionState
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
runtimeWorktreeIdsEqual
|
||||
} from './runtime-worktree-path-identity'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization'
|
||||
import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery'
|
||||
import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement'
|
||||
import type {
|
||||
@@ -27,6 +29,35 @@ import type {
|
||||
} from '../../shared/artifacts'
|
||||
|
||||
export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations {
|
||||
/** The host's saved session, when it still lists `tabId` under this worktree. */
|
||||
protected getPersistedSessionListingTerminalTab(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
): WorkspaceSessionState | null {
|
||||
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
|
||||
return session &&
|
||||
sessionWorktreeId &&
|
||||
session.tabsByWorktree[sessionWorktreeId]?.some((candidate) => candidate.id === tabId)
|
||||
? session
|
||||
: null
|
||||
}
|
||||
|
||||
protected hasPersistedTerminalSurfaceMembership(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string
|
||||
): boolean {
|
||||
// Why own partition: emptying a rotated runtime partition re-routes reads to an older copy
|
||||
// that can still list a surface retirement just removed.
|
||||
const layout = this.getPersistedSessionListingTerminalTab(
|
||||
worktreeId,
|
||||
tabId,
|
||||
this.getOwnWorkspaceSessionForWorktree(worktreeId)
|
||||
)?.terminalLayoutsByTabId[tabId]
|
||||
return Boolean(layout && layoutContainsLeafId(layout.root, leafId))
|
||||
}
|
||||
|
||||
protected hasExactPersistedTerminalSurfaceIdentity(expected: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
@@ -34,19 +65,10 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc
|
||||
ptyId: string
|
||||
incarnationId: string
|
||||
}): boolean {
|
||||
const session = this.getWorkspaceSessionForWorktree(expected.worktreeId)
|
||||
const sessionWorktreeId = session
|
||||
? resolveTerminalSessionWorktreeId(session, expected.worktreeId)
|
||||
: null
|
||||
if (!session || !sessionWorktreeId) {
|
||||
return false
|
||||
}
|
||||
const tab = session.tabsByWorktree[sessionWorktreeId]?.find(
|
||||
(candidate) => candidate.id === expected.tabId
|
||||
)
|
||||
const session = this.getPersistedSessionListingTerminalTab(expected.worktreeId, expected.tabId)
|
||||
const paneKey = makePaneKey(expected.tabId, expected.leafId)
|
||||
return Boolean(
|
||||
tab &&
|
||||
session &&
|
||||
session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] ===
|
||||
expected.ptyId &&
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId
|
||||
|
||||
@@ -141,6 +141,8 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand
|
||||
currentFence.pendingRegistration = false
|
||||
}
|
||||
}
|
||||
// Why: a listed surface's pending-handle → ready flip must not wait on a later renderer graph change.
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
// Why: the renderer's own PTY spawn is the reliable signal that the pending
|
||||
// mobile create's tab is live; publish its surface main-side (#7587).
|
||||
if (binding && paneKey) {
|
||||
|
||||
@@ -413,6 +413,62 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)',
|
||||
expect(targets.get(folderWorktreeId)).toBe(localSession)
|
||||
})
|
||||
|
||||
it.each<[string, string, Record<string, unknown>, ExecutionHostId]>([
|
||||
['local repo', 'repo::/wt', { id: 'repo' }, LOCAL_EXECUTION_HOST_ID],
|
||||
['SSH repo', 'repo::/wt', { id: 'repo', connectionId: 'c1' }, 'ssh:c1'],
|
||||
['runtime repo', 'repo::/wt', { id: 'repo', executionHostId: 'runtime:e1' }, 'runtime:e1'],
|
||||
['local folder', 'folder:f1', { folderId: 'f1' }, LOCAL_EXECUTION_HOST_ID],
|
||||
['SSH folder', 'folder:f1', { folderId: 'f1', connectionId: 'c1' }, 'ssh:c1'],
|
||||
['explicit SSH folder', 'folder:f1', { folderId: 'f1', executionHostId: 'ssh:c2' }, 'ssh:c2'],
|
||||
['runtime folder', 'folder:f1', { folderId: 'f1', executionHostId: 'runtime:e1' }, 'runtime:e1']
|
||||
])('reads a %s own saved partition', (_label, worktreeId, owner, ownerHostId) => {
|
||||
const listing = (id: string) => ({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const sessions = new Map<ExecutionHostId, WorkspaceSessionState>([
|
||||
[ownerHostId, listing('own')],
|
||||
['runtime:e0', listing('rotated')]
|
||||
])
|
||||
const { folderId, ...repoOrFolder } = owner
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only repos, folder workspaces and workspace sessions.
|
||||
const store = {
|
||||
getRepos: () => (folderId ? [] : [repoOrFolder]),
|
||||
getRepo: () => (folderId ? undefined : repoOrFolder),
|
||||
getFolderWorkspaces: () => (folderId ? [{ ...repoOrFolder, id: folderId }] : []),
|
||||
getWorkspaceSessionHostIds: () => [...sessions.keys()],
|
||||
getWorkspaceSession: (hostId: ExecutionHostId) =>
|
||||
sessions.get(hostId) ?? getDefaultWorkspaceSession()
|
||||
} as never
|
||||
const controller = new RuntimeWorkspaceSessionController({
|
||||
getStore: () => store,
|
||||
resolveFolderConnectionId: (workspace) => workspace.connectionId ?? null,
|
||||
hasRuntimeOwnedPtyCandidate: () => false
|
||||
})
|
||||
|
||||
expect(controller.getOwnPartition(worktreeId)).toBe(sessions.get(ownerHostId))
|
||||
expect(controller.get(worktreeId)).toBe(sessions.get(ownerHostId))
|
||||
|
||||
sessions.set(ownerHostId, getDefaultWorkspaceSession())
|
||||
const fellBack = controller.get(worktreeId) === sessions.get('runtime:e0')
|
||||
// Only a runtime owner rotates onto another copy; that copy is never read as its own.
|
||||
expect(fellBack).toBe(ownerHostId.startsWith('runtime:'))
|
||||
expect(controller.getOwnPartition(worktreeId)?.tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for provider retirement on a direct worktree stop', async () => {
|
||||
const harness = partitionedStore()
|
||||
const runtime = new OrcaRuntimeService(harness.store)
|
||||
|
||||
@@ -27,8 +27,8 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim
|
||||
snapshotVersion: snapshot.snapshotVersion + 1
|
||||
})
|
||||
if (options.immediate) {
|
||||
// Why: readiness/lifecycle changes are structural and must not wait
|
||||
// behind the title/status coalescing window.
|
||||
// Why: an exit is structural and must not wait behind the title/status window. A
|
||||
// registration's ready flip coalesces so it merges with the spawn's graph update.
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
return
|
||||
}
|
||||
@@ -126,7 +126,11 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim
|
||||
tab.parentTabId,
|
||||
tab.leafId,
|
||||
tab.ptyId
|
||||
)
|
||||
) ||
|
||||
// Why: after a cold restore the saved session is the only membership record until the PTY
|
||||
// registers. Frame-only; once listed, the surface's graph leaves pass the shared predicate
|
||||
// like any listed surface's. Host-side single-writer membership absorbs this.
|
||||
this.hasPersistedTerminalSurfaceMembership(snapshot.worktree, tab.parentTabId, tab.leafId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -107,6 +107,15 @@ export class RuntimeWorkspaceSessionController {
|
||||
return hostId ? (this.deps.getStore()?.getWorkspaceSession?.(hostId) ?? null) : null
|
||||
}
|
||||
|
||||
/** The session only when the worktree's own host partition owns it, not a rotated-owner fallback. */
|
||||
getOwnPartition(worktreeId: string): WorkspaceSessionState | null {
|
||||
const store = this.deps.getStore()
|
||||
const hostId = store ? this.getPreferredHostId(worktreeId, store) : null
|
||||
return hostId && hostId === this.tryGetHostId(worktreeId)
|
||||
? (store?.getWorkspaceSession?.(hostId) ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
set(worktreeId: string, session: WorkspaceSessionState): void {
|
||||
this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user