mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs (#30942)
* fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs The adoption rollback ran outside the store's durable transaction, so the store rebased it like a stale renderer write below the topology fence and kept the adopted tabs, layouts and incarnations; the next save then persisted them. Run the adoption through runDurableMutation, whose rollback phase skips that rebase, and refuse if a queued write changed the session it was checked against. Co-authored-by: Claude <noreply@anthropic.com> * fix(runtime): an adoption into an unwritten host partition is not refused as a conflict Co-authored-by: Claude <noreply@anthropic.com> * test: run the orphan adoption rollback test in the SQLite runtime project Co-authored-by: Claude <noreply@anthropic.com> * refactor(runtime): check and write an orphan adoption inside the store transaction A snapshot comparison could not see a queued binding write that edits the session in place. Plan the adoption against the session the transaction reads instead, so queued writes are validated against rather than guessed at. Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/host-terminal-close-persistence-durability.test.ts',
|
||||
'src/main/runtime/orca-runtime-terminal-close-records.test.ts',
|
||||
'src/main/runtime/orca-runtime-terminal-surface-close.test.ts',
|
||||
'src/main/runtime/runtime-terminal-orphan-adoption-rollback.test.ts',
|
||||
'src/main/runtime/orchestration/mailbox-owner.test.ts',
|
||||
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
|
||||
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
|
||||
|
||||
@@ -74,12 +74,10 @@ function adoptKeptPane(session: WorkspaceSessionState) {
|
||||
]),
|
||||
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
|
||||
},
|
||||
session,
|
||||
sessionWorktreeId: GIT_KEY,
|
||||
repoId: 'repo-1',
|
||||
worktreeWslDistro: null,
|
||||
currentRevision: 0,
|
||||
ports: {
|
||||
readSession: () => ({ session, sessionWorktreeId: GIT_KEY, currentRevision: 0 }),
|
||||
getPty: () => pty,
|
||||
getLeaves: () => [],
|
||||
getLeaf: () => undefined,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type {
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeTerminalOrphanAdoptionRequest
|
||||
} from '../../shared/runtime-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
|
||||
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { worktreeIdsEqual } from '../../shared/worktree/id'
|
||||
|
||||
/** The host session an adoption is checked against, read where the adoption is written. */
|
||||
export type RuntimeTerminalOrphanAdoptionSession = {
|
||||
session: WorkspaceSessionState
|
||||
sessionWorktreeId: string
|
||||
currentRevision: number
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionPlanPorts = {
|
||||
getPty: (handle: string) => RuntimePtyWorktreeRecord | null
|
||||
getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[]
|
||||
getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined
|
||||
/** The title display surfaces show for the PTY, which an adopted tab persists. */
|
||||
getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null
|
||||
getMobileSnapshots: () => Iterable<RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionPlanArgs = {
|
||||
/** The partition the session is read from and written to. */
|
||||
hostId: ExecutionHostId
|
||||
request: RuntimeTerminalOrphanAdoptionRequest
|
||||
workspace: TerminalWorkspaceLaunchScope
|
||||
inventory: PtyControllerInventory
|
||||
worktreeWslDistro: string | null
|
||||
ports: RuntimeTerminalOrphanAdoptionPlanPorts
|
||||
}
|
||||
|
||||
type ValidatedClaim = {
|
||||
claim: RuntimeTerminalOrphanAdoptionRequest['claims'][number]
|
||||
pty: RuntimePtyWorktreeRecord
|
||||
paneKey: string
|
||||
}
|
||||
|
||||
export type RuntimeTerminalOrphanAdoptionPlan =
|
||||
| { kind: 'replay'; validated: ValidatedClaim[]; currentRevision: number }
|
||||
| {
|
||||
kind: 'adopt'
|
||||
validated: ValidatedClaim[]
|
||||
currentRevision: number
|
||||
session: WorkspaceSessionState
|
||||
persisted: WorkspaceSessionState
|
||||
}
|
||||
|
||||
/** Checks every claim against `read` and builds the session to write; throws the refusal code. */
|
||||
export function planRuntimeTerminalOrphanAdoption(
|
||||
args: RuntimeTerminalOrphanAdoptionPlanArgs,
|
||||
read: RuntimeTerminalOrphanAdoptionSession
|
||||
): RuntimeTerminalOrphanAdoptionPlan {
|
||||
const { request, workspace, inventory, ports } = args
|
||||
const { session, sessionWorktreeId, currentRevision } = read
|
||||
const seenPtyIds = new Set<string>()
|
||||
const seenPaneKeys = new Set<string>()
|
||||
const validated = request.claims.map((claim) => {
|
||||
const paneKey = makePaneKey(claim.tabId, claim.leafId)
|
||||
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
|
||||
throw new Error('terminal_orphan_claim_duplicate')
|
||||
}
|
||||
seenPtyIds.add(claim.ptyId)
|
||||
seenPaneKeys.add(paneKey)
|
||||
const pty = ports.getPty(claim.terminal)
|
||||
const controllerIdentity = inventory.terminalIdentityByPtyId.get(claim.ptyId)
|
||||
if (
|
||||
!pty ||
|
||||
pty.ptyId !== claim.ptyId ||
|
||||
controllerIdentity?.handle !== claim.terminal ||
|
||||
controllerIdentity?.incarnationId !== claim.incarnationId ||
|
||||
!inventory.livePtyIds.has(claim.ptyId) ||
|
||||
!pty.connected ||
|
||||
!pty.incarnationId ||
|
||||
pty.incarnationId !== claim.incarnationId
|
||||
) {
|
||||
throw new Error('terminal_orphan_stale')
|
||||
}
|
||||
if (
|
||||
!worktreeIdsEqual(pty.worktreeId, workspace.id) ||
|
||||
!terminalOrphanExecutionOwnersEqual(
|
||||
{ connectionId: workspace.connectionId, wslDistro: args.worktreeWslDistro },
|
||||
{
|
||||
connectionId: pty.connectionId ?? null,
|
||||
...(controllerIdentity?.wslDistro !== undefined
|
||||
? { wslDistro: controllerIdentity.wslDistro }
|
||||
: process.platform === 'win32' && !workspace.connectionId
|
||||
? {}
|
||||
: { wslDistro: null })
|
||||
}
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
if (
|
||||
ports
|
||||
.getLeaves(claim.ptyId)
|
||||
.some(
|
||||
(owner) =>
|
||||
!worktreeIdsEqual(owner.worktreeId, workspace.id) ||
|
||||
owner.tabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_already_visual')
|
||||
}
|
||||
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return { claim, pty, paneKey }
|
||||
})
|
||||
|
||||
const persistedBindings = new Map<string, { worktreeId: string; paneKey: string }[]>()
|
||||
const addBinding = (ptyId: string, worktreeId: string, paneKey: string): void => {
|
||||
const bindings = persistedBindings.get(ptyId) ?? []
|
||||
bindings.push({ worktreeId, paneKey })
|
||||
persistedBindings.set(ptyId, bindings)
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
const layout = session.terminalLayoutsByTabId[tab.id]
|
||||
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
|
||||
if (ptyId) {
|
||||
addBinding(ptyId, worktreeId, makePaneKey(tab.id, leafId))
|
||||
}
|
||||
}
|
||||
if (tab.ptyId && !layout) {
|
||||
addBinding(tab.ptyId, worktreeId, tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
|
||||
const bindings = persistedBindings.get(ptyId) ?? []
|
||||
if (bindings.length > 1) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return bindings[0] ?? null
|
||||
}
|
||||
const isExactPersisted = validated.every(({ claim, paneKey }) => {
|
||||
const binding = persistedBinding(claim.ptyId)
|
||||
return (
|
||||
binding !== null &&
|
||||
worktreeIdsEqual(binding.worktreeId, workspace.id) &&
|
||||
binding.paneKey === paneKey &&
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
|
||||
)
|
||||
})
|
||||
if (isExactPersisted && sessionWorktreeId === workspace.id) {
|
||||
return { kind: 'replay', validated, currentRevision }
|
||||
}
|
||||
if (currentRevision !== request.expectedTopologyRevision) {
|
||||
throw new Error('terminal_topology_conflict')
|
||||
}
|
||||
|
||||
const { topologyTabsById, topologyGroups } = validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
validated
|
||||
)
|
||||
for (const { claim, paneKey } of validated) {
|
||||
const existingBinding = persistedBinding(claim.ptyId)
|
||||
if (
|
||||
existingBinding &&
|
||||
(!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) ||
|
||||
existingBinding.paneKey !== paneKey)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
const proposedPtyId =
|
||||
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
|
||||
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const graphOwner = ports.getLeaf(claim.tabId, claim.leafId)
|
||||
if (
|
||||
graphOwner &&
|
||||
(graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id))
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
if (
|
||||
Object.entries(session.tabsByWorktree).some(
|
||||
([ownerWorktreeId, tabs]) =>
|
||||
!worktreeIdsEqual(ownerWorktreeId, workspace.id) &&
|
||||
tabs.some((tab) => tab.id === claim.tabId)
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
// Why the close record too: it outlives a host restart, which the client's retirement proofs do not.
|
||||
if (
|
||||
session.terminalSurfaceTombstonesByPaneKey?.[paneKey] ||
|
||||
hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_retired')
|
||||
}
|
||||
for (const snapshot of ports.getMobileSnapshots()) {
|
||||
const surfaceOwner = snapshot.tabs.find(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' && tab.parentTabId === claim.tabId && tab.leafId === claim.leafId
|
||||
)
|
||||
if (
|
||||
surfaceOwner?.type === 'terminal' &&
|
||||
(snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const owner = snapshot.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.ptyId === claim.ptyId
|
||||
)
|
||||
if (
|
||||
owner?.type === 'terminal' &&
|
||||
(snapshot.worktree !== workspace.id ||
|
||||
owner.parentTabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persisted = buildRuntimeTerminalOrphanSession({
|
||||
hostId: args.hostId,
|
||||
session,
|
||||
sessionWorktreeId,
|
||||
worktreeId: workspace.id,
|
||||
request,
|
||||
validated,
|
||||
topologyTabsById,
|
||||
topologyGroups,
|
||||
getDisplayTitle: ports.getDisplayTitle
|
||||
})
|
||||
return { kind: 'adopt', validated, currentRevision, session, persisted }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeTerminalOrphanAdoptionRequest
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
GIT_KEY,
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
leaf
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
|
||||
import type { Store } from '../persistence'
|
||||
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
|
||||
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testStateDirRef.dir, getName: () => 'orca', getVersion: () => '0.0.0' },
|
||||
BrowserWindow: { fromId: () => null, getAllWindows: () => [] },
|
||||
webContents: { fromId: () => null },
|
||||
ipcMain: { on: () => {}, handle: () => {}, removeListener: () => {} },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
|
||||
const testStateDirRef = vi.hoisted(() => ({ dir: '' }))
|
||||
|
||||
const REPO_ID = 'repo-1'
|
||||
const ORPHAN_TAB = 'tab-orphan'
|
||||
const ORPHAN_LEAF = leaf(2)
|
||||
const ORPHAN_PANE = `${ORPHAN_TAB}:${ORPHAN_LEAF}`
|
||||
|
||||
function seededSession(revision: number | undefined): WorkspaceSessionState {
|
||||
const session = addWorkspace(emptySession(), GIT_KEY, [
|
||||
{ id: 'group-1', tabs: [{ id: 'tab-kept', leaves: [[leaf(1), 'pty-kept']] }] }
|
||||
])
|
||||
return revision === undefined
|
||||
? session
|
||||
: { ...session, terminalTopologyRevisionByRepoId: { [REPO_ID]: revision } }
|
||||
}
|
||||
|
||||
/** The next profile write fails as a known (not indeterminate) disk failure. */
|
||||
function failNextProfileWrite(): void {
|
||||
const fail = (): never => {
|
||||
throw new Error('disk_full')
|
||||
}
|
||||
for (const method of ['writeSerializedDomains', 'writeCompleteSerializedDomains'] as const) {
|
||||
vi.spyOn(ProfileStateSqliteAuthority.prototype, method).mockImplementationOnce(fail)
|
||||
}
|
||||
}
|
||||
|
||||
function adoptWithFailingFlush(store: Store, revision: number): Promise<unknown> {
|
||||
failNextProfileWrite()
|
||||
return adopt(store, revision, (mutate) => store.runDurableMutation(mutate))
|
||||
}
|
||||
|
||||
function adopt(
|
||||
store: Store,
|
||||
revision: number,
|
||||
runDurableMutation: Store['runDurableMutation'],
|
||||
host: { hostId: ExecutionHostId; connectionId: string | null } = {
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
connectionId: null
|
||||
}
|
||||
): Promise<unknown> {
|
||||
const request: RuntimeTerminalOrphanAdoptionRequest = {
|
||||
worktree: GIT_KEY,
|
||||
expectedTopologyRevision: revision,
|
||||
claims: [
|
||||
{
|
||||
terminal: 'term-orphan',
|
||||
ptyId: 'pty-orphan',
|
||||
incarnationId: 'inc-orphan',
|
||||
tabId: ORPHAN_TAB,
|
||||
leafId: ORPHAN_LEAF
|
||||
}
|
||||
]
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption reads only these PTY fields.
|
||||
const pty = {
|
||||
ptyId: 'pty-orphan',
|
||||
incarnationId: 'inc-orphan',
|
||||
worktreeId: GIT_KEY,
|
||||
connectionId: host.connectionId,
|
||||
connected: true,
|
||||
tabId: null,
|
||||
paneKey: null
|
||||
} as RuntimePtyWorktreeRecord
|
||||
return adoptRuntimeTerminalOrphansFromInventory({
|
||||
hostId: host.hostId,
|
||||
request,
|
||||
workspace: {
|
||||
id: GIT_KEY,
|
||||
path: '/Users/dev/orca',
|
||||
connectionId: host.connectionId,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
},
|
||||
inventory: {
|
||||
livePtyIds: new Set(['pty-orphan']),
|
||||
allLivePtyIds: new Set(['pty-orphan']),
|
||||
terminalIdentityByPtyId: new Map([
|
||||
['pty-orphan', { handle: 'term-orphan', incarnationId: 'inc-orphan', wslDistro: null }]
|
||||
]),
|
||||
queriedHostIds: new Set([host.hostId])
|
||||
},
|
||||
repoId: REPO_ID,
|
||||
worktreeWslDistro: null,
|
||||
ports: {
|
||||
readSession: () => {
|
||||
const session = store.getWorkspaceSession(host.hostId)
|
||||
return {
|
||||
session,
|
||||
sessionWorktreeId: GIT_KEY,
|
||||
currentRevision: session.terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0
|
||||
}
|
||||
},
|
||||
getPty: () => pty,
|
||||
getLeaves: () => [],
|
||||
getLeaf: () => undefined,
|
||||
getDisplayTitle: () => null,
|
||||
replayPersistedSurface: vi.fn(),
|
||||
recordAdoptedSurface: vi.fn(),
|
||||
getMobileSnapshots: () => [],
|
||||
getSession: () => store.getWorkspaceSession(host.hostId),
|
||||
setSession: (_worktreeId, next) => store.setWorkspaceSession(next, host.hostId),
|
||||
runDurableMutation,
|
||||
flushSession: () => store.flushPendingOrThrowAsync(),
|
||||
hydrateSession: vi.fn(),
|
||||
notifySessionChanged: vi.fn(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the caller reads only `adopted`.
|
||||
getSnapshot: () => ({}) as RuntimeMobileSessionTabsResult
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('orphan adoption whose flush fails', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-rollback-'))
|
||||
testStateDirRef.dir = testState.dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a repo with no fence yet', undefined],
|
||||
['a repo whose fence is armed', 3]
|
||||
])('leaves no adopted tab, layout or incarnation behind in %s', async (_label, revision) => {
|
||||
const store = createStore()
|
||||
store.setWorkspaceSession(seededSession(revision))
|
||||
const before = store.getWorkspaceSession()
|
||||
|
||||
await expect(adoptWithFailingFlush(store, revision ?? 0)).rejects.toThrow('disk_full')
|
||||
|
||||
const after = store.getWorkspaceSession()
|
||||
expect(after.tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(['tab-kept'])
|
||||
expect(after.terminalLayoutsByTabId[ORPHAN_TAB]).toBeUndefined()
|
||||
expect(after.terminalPtyIncarnationsByPaneKey?.[ORPHAN_PANE]).toBeUndefined()
|
||||
expect(after.tabGroups?.[GIT_KEY]).toEqual(before.tabGroups?.[GIT_KEY])
|
||||
})
|
||||
|
||||
it('keeps an unrelated edit that landed while it waited to write', async () => {
|
||||
const store = createStore()
|
||||
store.setWorkspaceSession(seededSession(3))
|
||||
|
||||
const adoption = adopt(store, 3, (mutate) => {
|
||||
store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'tab-kept' })
|
||||
return store.runDurableMutation(mutate)
|
||||
})
|
||||
|
||||
await expect(adoption).resolves.toMatchObject({ adopted: true })
|
||||
const after = store.getWorkspaceSession()
|
||||
expect(after.tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(['tab-kept', ORPHAN_TAB])
|
||||
expect(after.activeTabId).toBe('tab-kept')
|
||||
})
|
||||
|
||||
it('refuses when a binding write queued ahead edited the session in place', async () => {
|
||||
const store = createStore()
|
||||
store.setWorkspaceSession(seededSession(3))
|
||||
|
||||
const adoption = adopt(store, 3, (mutate) => {
|
||||
void store.persistPtyBinding({
|
||||
worktreeId: GIT_KEY,
|
||||
tabId: 'tab-bound',
|
||||
leafId: leaf(3),
|
||||
ptyId: 'pty-bound'
|
||||
})
|
||||
return store.runDurableMutation(mutate)
|
||||
})
|
||||
|
||||
await expect(adoption).rejects.toThrow('terminal_topology_conflict')
|
||||
expect(store.getWorkspaceSession().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual([
|
||||
'tab-kept',
|
||||
'tab-bound'
|
||||
])
|
||||
})
|
||||
|
||||
it('adopts into an SSH host partition that was never written', async () => {
|
||||
const store = createStore()
|
||||
const host = { hostId: toSshExecutionHostId('target-1'), connectionId: 'target-1' }
|
||||
|
||||
const adoption = adopt(store, 0, (mutate) => store.runDurableMutation(mutate), host)
|
||||
|
||||
await expect(adoption).resolves.toMatchObject({ adopted: true })
|
||||
expect(
|
||||
store.getWorkspaceSession(host.hostId).tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)
|
||||
).toEqual([ORPHAN_TAB])
|
||||
})
|
||||
|
||||
it('still rebases a renderer replay of the failed adoption away', async () => {
|
||||
const store = createStore()
|
||||
store.setWorkspaceSession(seededSession(3))
|
||||
await expect(adoptWithFailingFlush(store, 3)).rejects.toThrow('disk_full')
|
||||
const after = store.getWorkspaceSession()
|
||||
const keptTab = after.tabsByWorktree[GIT_KEY]![0]!
|
||||
const replay: WorkspaceSessionState = {
|
||||
...after,
|
||||
tabsByWorktree: { [GIT_KEY]: [keptTab, { ...keptTab, id: ORPHAN_TAB, ptyId: 'pty-orphan' }] }
|
||||
}
|
||||
delete replay.terminalTopologyRevisionByRepoId
|
||||
|
||||
store.setWorkspaceSession(replay)
|
||||
|
||||
expect(store.getWorkspaceSession().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual([
|
||||
'tab-kept'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,254 +1,114 @@
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeTerminalOrphanAdoptionRequest,
|
||||
RuntimeTerminalOrphanAdoptionResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
|
||||
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback'
|
||||
import { worktreeIdsEqual } from '../../shared/worktree/id'
|
||||
import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state'
|
||||
import {
|
||||
planRuntimeTerminalOrphanAdoption,
|
||||
type RuntimeTerminalOrphanAdoptionPlan,
|
||||
type RuntimeTerminalOrphanAdoptionPlanArgs,
|
||||
type RuntimeTerminalOrphanAdoptionPlanPorts,
|
||||
type RuntimeTerminalOrphanAdoptionSession
|
||||
} from './runtime-terminal-orphan-adoption-plan'
|
||||
|
||||
type RuntimeTerminalOrphanAdoptionPorts = {
|
||||
getPty: (handle: string) => RuntimePtyWorktreeRecord | null
|
||||
getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[]
|
||||
getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined
|
||||
/** The title display surfaces show for the PTY, which an adopted tab persists. */
|
||||
getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null
|
||||
type AdoptPlan = Extract<RuntimeTerminalOrphanAdoptionPlan, { kind: 'adopt' }>
|
||||
|
||||
type RuntimeTerminalOrphanAdoptionPorts = RuntimeTerminalOrphanAdoptionPlanPorts & {
|
||||
/** Replays a binding the session already held: names the pane without claiming the graph holds it. */
|
||||
replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void
|
||||
/** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */
|
||||
recordAdoptedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void
|
||||
getMobileSnapshots: () => Iterable<RuntimeMobileSessionTabsSnapshot>
|
||||
/** The session as it is now; throws the refusal when it cannot be read. */
|
||||
readSession: () => RuntimeTerminalOrphanAdoptionSession
|
||||
getSession: (worktreeId: string) => WorkspaceSessionState | null
|
||||
setSession: (worktreeId: string, session: WorkspaceSessionState) => void
|
||||
/** The store's durable transaction; without one, `flushSession` writes and the rollback is manual. */
|
||||
runDurableMutation?: <T>(mutate: () => DurableProfileStateMutation<T>) => Promise<T>
|
||||
flushSession: () => Promise<void>
|
||||
hydrateSession: (worktreeId: string) => void
|
||||
notifySessionChanged: (worktreeId: string) => void
|
||||
getSnapshot: (worktreeId: string) => RuntimeMobileSessionTabsResult
|
||||
}
|
||||
|
||||
export async function adoptRuntimeTerminalOrphansFromInventory(args: {
|
||||
/** The partition the session is read from and written to. */
|
||||
hostId: ExecutionHostId
|
||||
request: RuntimeTerminalOrphanAdoptionRequest
|
||||
workspace: TerminalWorkspaceLaunchScope
|
||||
inventory: PtyControllerInventory
|
||||
session: WorkspaceSessionState
|
||||
sessionWorktreeId: string
|
||||
repoId: string
|
||||
worktreeWslDistro: string | null
|
||||
currentRevision: number
|
||||
ports: RuntimeTerminalOrphanAdoptionPorts
|
||||
}): Promise<RuntimeTerminalOrphanAdoptionResult> {
|
||||
const { request, workspace, inventory, session, sessionWorktreeId, currentRevision, ports } = args
|
||||
const seenPtyIds = new Set<string>()
|
||||
const seenPaneKeys = new Set<string>()
|
||||
const validated = request.claims.map((claim) => {
|
||||
const paneKey = makePaneKey(claim.tabId, claim.leafId)
|
||||
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
|
||||
throw new Error('terminal_orphan_claim_duplicate')
|
||||
}
|
||||
seenPtyIds.add(claim.ptyId)
|
||||
seenPaneKeys.add(paneKey)
|
||||
const pty = ports.getPty(claim.terminal)
|
||||
const controllerIdentity = inventory.terminalIdentityByPtyId.get(claim.ptyId)
|
||||
if (
|
||||
!pty ||
|
||||
pty.ptyId !== claim.ptyId ||
|
||||
controllerIdentity?.handle !== claim.terminal ||
|
||||
controllerIdentity?.incarnationId !== claim.incarnationId ||
|
||||
!inventory.livePtyIds.has(claim.ptyId) ||
|
||||
!pty.connected ||
|
||||
!pty.incarnationId ||
|
||||
pty.incarnationId !== claim.incarnationId
|
||||
) {
|
||||
throw new Error('terminal_orphan_stale')
|
||||
}
|
||||
if (
|
||||
!worktreeIdsEqual(pty.worktreeId, workspace.id) ||
|
||||
!terminalOrphanExecutionOwnersEqual(
|
||||
{ connectionId: workspace.connectionId, wslDistro: args.worktreeWslDistro },
|
||||
{
|
||||
connectionId: pty.connectionId ?? null,
|
||||
...(controllerIdentity?.wslDistro !== undefined
|
||||
? { wslDistro: controllerIdentity.wslDistro }
|
||||
: process.platform === 'win32' && !workspace.connectionId
|
||||
? {}
|
||||
: { wslDistro: null })
|
||||
}
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
if (
|
||||
ports
|
||||
.getLeaves(claim.ptyId)
|
||||
.some(
|
||||
(owner) =>
|
||||
!worktreeIdsEqual(owner.worktreeId, workspace.id) ||
|
||||
owner.tabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_already_visual')
|
||||
}
|
||||
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return { claim, pty, paneKey }
|
||||
})
|
||||
|
||||
const persistedBindings = new Map<string, { worktreeId: string; paneKey: string }[]>()
|
||||
const addBinding = (ptyId: string, worktreeId: string, paneKey: string): void => {
|
||||
const bindings = persistedBindings.get(ptyId) ?? []
|
||||
bindings.push({ worktreeId, paneKey })
|
||||
persistedBindings.set(ptyId, bindings)
|
||||
export async function adoptRuntimeTerminalOrphansFromInventory(
|
||||
args: RuntimeTerminalOrphanAdoptionPlanArgs & {
|
||||
repoId: string
|
||||
ports: RuntimeTerminalOrphanAdoptionPorts
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
const layout = session.terminalLayoutsByTabId[tab.id]
|
||||
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
|
||||
if (ptyId) {
|
||||
addBinding(ptyId, worktreeId, makePaneKey(tab.id, leafId))
|
||||
}
|
||||
): Promise<RuntimeTerminalOrphanAdoptionResult> {
|
||||
const { workspace, ports } = args
|
||||
const plan = (): RuntimeTerminalOrphanAdoptionPlan =>
|
||||
planRuntimeTerminalOrphanAdoption(args, ports.readSession())
|
||||
const stage = (planned: AdoptPlan): (() => void) => {
|
||||
ports.setSession(workspace.id, planned.persisted)
|
||||
const staged = ports.getSession(workspace.id)
|
||||
return () => {
|
||||
const current = ports.getSession(workspace.id)
|
||||
if (!staged || !current) {
|
||||
return
|
||||
}
|
||||
if (tab.ptyId && !layout) {
|
||||
addBinding(tab.ptyId, worktreeId, tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
|
||||
const bindings = persistedBindings.get(ptyId) ?? []
|
||||
if (bindings.length > 1) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return bindings[0] ?? null
|
||||
}
|
||||
const isExactPersisted = validated.every(({ claim, paneKey }) => {
|
||||
const binding = persistedBinding(claim.ptyId)
|
||||
return (
|
||||
binding !== null &&
|
||||
worktreeIdsEqual(binding.worktreeId, workspace.id) &&
|
||||
binding.paneKey === paneKey &&
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
|
||||
)
|
||||
})
|
||||
if (isExactPersisted && sessionWorktreeId === workspace.id) {
|
||||
for (const { claim, pty, paneKey } of validated) {
|
||||
ports.replayPersistedSurface(pty, claim.tabId, paneKey)
|
||||
}
|
||||
return {
|
||||
adopted: false,
|
||||
topologyRevision: currentRevision,
|
||||
snapshot: ports.getSnapshot(workspace.id)
|
||||
}
|
||||
}
|
||||
if (currentRevision !== request.expectedTopologyRevision) {
|
||||
throw new Error('terminal_topology_conflict')
|
||||
}
|
||||
|
||||
const { topologyTabsById, topologyGroups } = validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
validated
|
||||
)
|
||||
for (const { claim, paneKey } of validated) {
|
||||
const existingBinding = persistedBinding(claim.ptyId)
|
||||
if (
|
||||
existingBinding &&
|
||||
(!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) ||
|
||||
existingBinding.paneKey !== paneKey)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
const proposedPtyId =
|
||||
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
|
||||
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const graphOwner = ports.getLeaf(claim.tabId, claim.leafId)
|
||||
if (
|
||||
graphOwner &&
|
||||
(graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id))
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
if (
|
||||
Object.entries(session.tabsByWorktree).some(
|
||||
([ownerWorktreeId, tabs]) =>
|
||||
!worktreeIdsEqual(ownerWorktreeId, workspace.id) &&
|
||||
tabs.some((tab) => tab.id === claim.tabId)
|
||||
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(
|
||||
planned.session,
|
||||
staged,
|
||||
current
|
||||
)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
// Why the close record too: it outlives a host restart, which the client's retirement proofs do not.
|
||||
if (
|
||||
session.terminalSurfaceTombstonesByPaneKey?.[paneKey] ||
|
||||
hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_retired')
|
||||
}
|
||||
for (const snapshot of ports.getMobileSnapshots()) {
|
||||
const surfaceOwner = snapshot.tabs.find(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' && tab.parentTabId === claim.tabId && tab.leafId === claim.leafId
|
||||
)
|
||||
if (
|
||||
surfaceOwner?.type === 'terminal' &&
|
||||
(snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_surface_occupied')
|
||||
}
|
||||
const owner = snapshot.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.ptyId === claim.ptyId
|
||||
)
|
||||
if (
|
||||
owner?.type === 'terminal' &&
|
||||
(snapshot.worktree !== workspace.id ||
|
||||
owner.parentTabId !== claim.tabId ||
|
||||
owner.leafId !== claim.leafId)
|
||||
) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persisted = buildRuntimeTerminalOrphanSession({
|
||||
hostId: args.hostId,
|
||||
session,
|
||||
sessionWorktreeId,
|
||||
worktreeId: workspace.id,
|
||||
request,
|
||||
validated,
|
||||
topologyTabsById,
|
||||
topologyGroups,
|
||||
getDisplayTitle: ports.getDisplayTitle
|
||||
})
|
||||
let staged: WorkspaceSessionState | null = null
|
||||
try {
|
||||
ports.setSession(workspace.id, persisted)
|
||||
staged = ports.getSession(workspace.id)
|
||||
await ports.flushSession()
|
||||
} catch (error) {
|
||||
const current = ports.getSession(workspace.id)
|
||||
if (staged && current) {
|
||||
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
|
||||
if (rolledBack !== current) {
|
||||
ports.setSession(workspace.id, rolledBack)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
for (const { claim, pty, paneKey } of validated) {
|
||||
let planned: RuntimeTerminalOrphanAdoptionPlan
|
||||
if (ports.runDurableMutation) {
|
||||
// Why checked and written inside the store's transaction: a write queued ahead may change the
|
||||
// session, and only the transaction's rollback skips the topology-fence rebase, which treats
|
||||
// a write below the fence (this rollback) as a stale renderer replay and keeps the adoption.
|
||||
const outcome = await ports.runDurableMutation<RuntimeTerminalOrphanAdoptionPlan | Error>(
|
||||
() => {
|
||||
let next: RuntimeTerminalOrphanAdoptionPlan
|
||||
try {
|
||||
next = plan()
|
||||
} catch (error) {
|
||||
// A throwing callback freezes the store; a refusal must not.
|
||||
return {
|
||||
value: error instanceof Error ? error : new Error(String(error)),
|
||||
persist: false
|
||||
}
|
||||
}
|
||||
return next.kind === 'replay'
|
||||
? { value: next, persist: false }
|
||||
: { value: next, rollback: stage(next) }
|
||||
}
|
||||
)
|
||||
if (outcome instanceof Error) {
|
||||
throw outcome
|
||||
}
|
||||
planned = outcome
|
||||
} else {
|
||||
planned = plan()
|
||||
if (planned.kind === 'adopt') {
|
||||
const rollback = stage(planned)
|
||||
try {
|
||||
await ports.flushSession()
|
||||
} catch (error) {
|
||||
rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (planned.kind === 'replay') {
|
||||
for (const { claim, pty, paneKey } of planned.validated) {
|
||||
ports.replayPersistedSurface(pty, claim.tabId, paneKey)
|
||||
}
|
||||
return {
|
||||
adopted: false,
|
||||
topologyRevision: planned.currentRevision,
|
||||
snapshot: ports.getSnapshot(workspace.id)
|
||||
}
|
||||
}
|
||||
for (const { claim, pty, paneKey } of planned.validated) {
|
||||
ports.recordAdoptedSurface(pty, claim.tabId, paneKey)
|
||||
}
|
||||
ports.hydrateSession(workspace.id)
|
||||
@@ -256,7 +116,8 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: {
|
||||
return {
|
||||
adopted: true,
|
||||
topologyRevision:
|
||||
persisted.terminalTopologyRevisionByRepoId?.[args.repoId] ?? currentRevision + 1,
|
||||
planned.persisted.terminalTopologyRevisionByRepoId?.[args.repoId] ??
|
||||
planned.currentRevision + 1,
|
||||
snapshot: ports.getSnapshot(workspace.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,27 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
|
||||
inventory: PtyControllerInventory
|
||||
): Promise<RuntimeTerminalOrphanAdoptionResult> {
|
||||
const store = this.store
|
||||
const session = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
if (
|
||||
!store?.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow) ||
|
||||
!session
|
||||
) {
|
||||
if (!store?.setWorkspaceSession || (!store.flushPendingOrThrowAsync && !store.flushOrThrow)) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
|
||||
if (!sessionWorktreeId) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
const readSession = () => {
|
||||
const session = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
if (!session) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
|
||||
if (!sessionWorktreeId) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
return {
|
||||
session,
|
||||
sessionWorktreeId,
|
||||
currentRevision: this.getTerminalTopologyRevision(workspace.id)
|
||||
}
|
||||
}
|
||||
// Refuse an unreadable session before any owner lookup, as before.
|
||||
readSession()
|
||||
const runDurableMutation = store.runDurableMutation?.bind(store)
|
||||
const worktreeConnectionId = workspace.connectionId
|
||||
let worktreeWslDistro: string | null = null
|
||||
if (!worktreeConnectionId && workspace.repo) {
|
||||
@@ -60,12 +69,10 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
|
||||
request,
|
||||
workspace,
|
||||
inventory,
|
||||
session,
|
||||
sessionWorktreeId,
|
||||
repoId: getRepoIdFromWorktreeId(workspace.id),
|
||||
worktreeWslDistro,
|
||||
currentRevision: this.getTerminalTopologyRevision(workspace.id),
|
||||
ports: {
|
||||
readSession,
|
||||
getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null,
|
||||
getLeaves: (ptyId) => this.getLeavesForPty(ptyId),
|
||||
getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)),
|
||||
@@ -77,6 +84,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
|
||||
getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(),
|
||||
getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
|
||||
setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next),
|
||||
...(runDurableMutation ? { runDurableMutation } : {}),
|
||||
flushSession: () => this.flushWorkspaceSessionOrThrowAsync(),
|
||||
hydrateSession: (worktreeId) =>
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
|
||||
Reference in New Issue
Block a user