diff --git a/src/main/deleted-folder-session-tombstones.ts b/src/main/deleted-folder-session-tombstones.ts new file mode 100644 index 00000000000..af5d0da36c9 --- /dev/null +++ b/src/main/deleted-folder-session-tombstones.ts @@ -0,0 +1,71 @@ +import type { ExecutionHostId } from '../shared/execution-host' +import type { + DeletedFolderWorkspaceSessionTombstone, + PersistedState, + WorkspaceKey +} from '../shared/types' + +const MAX_TOMBSTONES = 512 +export const MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1000 +export const MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 +const MAX_HOST_IDS = 32 +const MAX_TAB_OWNERS = 256 + +export function boundDeletedFolderTombstoneEvidence( + tombstone: DeletedFolderWorkspaceSessionTombstone +): DeletedFolderWorkspaceSessionTombstone { + const tabEntries = Object.entries(tombstone.tabConnectionIdsByHostId).flatMap(([hostId, tabs]) => + Object.entries(tabs ?? {}).map( + ([tabId, connectionId]) => [hostId as ExecutionHostId, tabId, connectionId] as const + ) + ) + const retainedTabEntries = tabEntries.slice(Math.max(0, tabEntries.length - MAX_TAB_OWNERS)) + const retainedHostIds = [ + ...new Set([...tombstone.hostIds, ...retainedTabEntries.map(([hostId]) => hostId)]) + ].slice(-MAX_HOST_IDS) + const retainedHostIdSet = new Set(retainedHostIds) + const tabConnectionIdsByHostId: DeletedFolderWorkspaceSessionTombstone['tabConnectionIdsByHostId'] = + {} + for (const [hostId, tabId, connectionId] of retainedTabEntries) { + if (!retainedHostIdSet.has(hostId)) { + continue + } + tabConnectionIdsByHostId[hostId] = { + ...tabConnectionIdsByHostId[hostId], + [tabId]: connectionId + } + } + return { ...tombstone, hostIds: retainedHostIds, tabConnectionIdsByHostId } +} + +export function getDeletedFolderTombstoneEvictionKeys( + tombstones: NonNullable, + now: number +): WorkspaceKey[] { + const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) => + tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : [] + ) + const expired = entries.filter( + ({ tombstone }) => now - tombstone.deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + ) + const expiredKeys = new Set(expired.map(({ workspaceKey }) => workspaceKey)) + const retained = entries.filter(({ workspaceKey }) => !expiredKeys.has(workspaceKey)) + const excess = Math.max(0, retained.length - MAX_TOMBSTONES) + if (excess === 0) { + return expired.map(({ workspaceKey }) => workspaceKey) + } + const softEvictions = retained + .filter( + ({ tombstone }) => now - tombstone.deletedAt >= MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + ) + .sort((left, right) => + left.tombstone.deletedAt === right.tombstone.deletedAt + ? left.index - right.index + : left.tombstone.deletedAt - right.tombstone.deletedAt + ) + .slice(0, excess) + return [ + ...expired.map(({ workspaceKey }) => workspaceKey), + ...softEvictions.map(({ workspaceKey }) => workspaceKey) + ] +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 54ac8b3d4a1..d6371e280d5 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5148,6 +5148,7 @@ describe('Store', () => { state.deletedFolderWorkspaceSessionTombstones = { 'folder:partial-tombstone': { connectionId: null, + deletedAt: undefined as unknown as number, hostIds: ['local'], tabConnectionIdsByHostId: { local: { valid: null, invalid: 42 as unknown as string } @@ -5171,6 +5172,7 @@ describe('Store', () => { ] ).toEqual({ connectionId: null, + deletedAt: expect.any(Number), hostIds: ['local'], tabConnectionIdsByHostId: { local: { valid: null } } }) @@ -5183,10 +5185,16 @@ describe('Store', () => { const workspaceKeys = Array.from({ length: 514 }, (_, index) => folderWorkspaceKey(`deleted-${index}`) ) + const deletedAt = Date.now() - 2 * 24 * 60 * 60 * 1000 state.deletedFolderWorkspaceSessionTombstones = Object.fromEntries( - workspaceKeys.map((workspaceKey) => [ + workspaceKeys.map((workspaceKey, index) => [ workspaceKey, - { connectionId: null, hostIds: ['local'], tabConnectionIdsByHostId: {} } + { + connectionId: null, + deletedAt: deletedAt + index, + hostIds: ['local'], + tabConnectionIdsByHostId: {} + } ]) ) as PersistedState['deletedFolderWorkspaceSessionTombstones'] writeDataFile(state) @@ -5207,30 +5215,68 @@ describe('Store', () => { expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined() }) - it('bounds deleted-folder tombstones during repeated workspace churn', async () => { + it('retains recent deletion fences before applying the tombstone soft cap', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Tombstone churn', parentPath: '/workspace/churn', createdFrom: 'folder-scan' }) - const workspaceKeys: ReturnType[] = [] - for (let index = 0; index < 514; index += 1) { - const workspace = store.createFolderWorkspace({ - projectGroupId: group.id, - name: `Deleted ${index}` - }) - workspaceKeys.push(folderWorkspaceKey(workspace.id)) - expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const workspaceKeys: ReturnType[] = [] + for (let index = 0; index < 514; index += 1) { + const workspace = store.createFolderWorkspace({ + projectGroupId: group.id, + name: `Deleted ${index}` + }) + workspaceKeys.push(folderWorkspaceKey(workspace.id)) + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + } + store.flush() + let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones + expect(Object.keys(tombstones ?? {})).toHaveLength(514) + expect(tombstones?.[workspaceKeys[0]!]).toBeDefined() + + vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z')) + const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' }) + expect(store.removeFolderWorkspace(latest.id)).toBe(true) + store.flush() + + tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones + expect(Object.keys(tombstones ?? {})).toHaveLength(512) + expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() + expect(tombstones?.[folderWorkspaceKey(latest.id)]).toBeDefined() + } finally { + vi.useRealTimers() + } + }) + + it('removes deleted-folder tombstones after the stale-write retention window', async () => { + const initial = await createStore() + initial.flush() + const state = readDataFile() as PersistedState + state.deletedFolderWorkspaceSessionTombstones = { + 'folder:expired-tombstone': { + connectionId: null, + deletedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + hostIds: ['local'], + tabConnectionIdsByHostId: {} + } + } + writeDataFile(state) + + vi.useFakeTimers() + try { + const restored = await createStore() + await vi.advanceTimersByTimeAsync(1_000) + await restored.waitForPendingWrite() + } finally { + vi.useRealTimers() } - store.flush() - - const tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones - expect(Object.keys(tombstones ?? {})).toHaveLength(512) - expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() - expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined() - expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined() + expect((readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones).toEqual({}) }) it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { @@ -6785,6 +6831,126 @@ describe('Store', () => { ).toThrow('folder_workspace_not_found') }) + it('keeps a recent remote stale-write fence through rapid folder deletion churn', async () => { + const store = await createStore() + const connectionId = 'rapid-churn-remote' + const hostId = toRuntimeExecutionHostId('rapid-churn-runtime') + const group = store.createProjectGroup({ + name: 'Rapid churn', + parentPath: '/remote/rapid-churn', + connectionId, + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, connectionId }) + const workspaceKey = folderWorkspaceKey(workspace.id) + const staleSession = { + ...getDefaultWorkspaceSession(), + activeWorkspaceKey: workspaceKey, + tabsByWorktree: { + [workspaceKey]: [makeTerminalTab({ id: 'rapid-churn-stale-tab', worktreeId: workspaceKey })] + } + } satisfies WorkspaceSessionState + store.setWorkspaceSession(staleSession, hostId) + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + for (let index = 0; index < 512; index += 1) { + const churned = store.createFolderWorkspace({ + projectGroupId: group.id, + name: `Churned ${index}` + }) + expect(store.removeFolderWorkspace(churned.id)).toBe(true) + } + + store.setWorkspaceSession(staleSession, hostId) + store.upsertSshRemotePtyLease({ + targetId: connectionId, + ptyId: 'rapid-churn-stale-pty', + worktreeId: workspaceKey, + tabId: 'rapid-churn-stale-tab', + leafId: TEST_LEAF_1, + state: 'detached' + }) + + expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toBeUndefined() + expect(store.getSshRemotePtyLeases()).toEqual([]) + }) + + it('bounds detailed tab-owner evidence for one deleted folder', async () => { + const store = await createStore() + const hostId = toRuntimeExecutionHostId('tab-evidence-runtime') + const group = store.createProjectGroup({ + name: 'Tab evidence', + parentPath: '/remote/tab-evidence', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id }) + const workspaceKey = folderWorkspaceKey(workspace.id) + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + + for (let index = 0; index < 300; index += 1) { + const tabId = `delayed-tab-${index}` + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })] + } + }, + hostId + ) + } + store.flush() + + const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[ + workspaceKey + ] + expect(Object.keys(tombstone?.tabConnectionIdsByHostId[hostId] ?? {})).toHaveLength(256) + const ownerIndex = ( + store as unknown as { + deletedFolderOwnersByHostAndTabId: Map> + } + ).deletedFolderOwnersByHostAndTabId + expect(ownerIndex.get(hostId)?.size).toBe(256) + }) + + it('bounds host-partition evidence for one deleted folder', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Host evidence', + parentPath: '/remote/host-evidence', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id }) + const workspaceKey = folderWorkspaceKey(workspace.id) + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + + for (let index = 0; index < 40; index += 1) { + const hostId = toRuntimeExecutionHostId(`host-evidence-${index}`) + const tabId = `host-evidence-tab-${index}` + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })] + } + }, + hostId + ) + } + store.flush() + + const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[ + workspaceKey + ] + expect(tombstone?.hostIds).toHaveLength(32) + expect(Object.keys(tombstone?.tabConnectionIdsByHostId ?? {})).toHaveLength(32) + const ownerIndex = ( + store as unknown as { + deletedFolderOwnersByHostAndTabId: Map> + } + ).deletedFolderOwnersByHostAndTabId + expect(ownerIndex.size).toBe(32) + }) + it('durably fences a delayed unified-only folder tab before a layout-only write', async () => { const store = await createStore() const connectionId = 'unified-only-delayed-target' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2a9d9e527d4..49e4d8c09cb 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -88,6 +88,12 @@ import { import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' +import { + boundDeletedFolderTombstoneEvidence, + getDeletedFolderTombstoneEvictionKeys, + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS, + MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS +} from './deleted-folder-session-tombstones' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { @@ -567,8 +573,6 @@ const WORKSPACE_SESSION_PATCH_OWNER_KEYS = new Set( 'terminalSurfaceTombstonesByPaneKey', 'activeWorkspaceExecutionHostId' ]) -const MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES = 512 - function logPersistenceStartupMilestone( event: string, details: Record = {} @@ -2871,6 +2875,7 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( if (!value || typeof value !== 'object' || Array.isArray(value)) { return { tombstones, changed } } + const now = Date.now() for (const [workspaceKey, rawTombstone] of Object.entries(value)) { const scope = parseWorkspaceKey(workspaceKey) if ( @@ -2885,6 +2890,17 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( } const raw = rawTombstone as Partial const connectionId = typeof raw.connectionId === 'string' ? raw.connectionId : null + const deletedAt = + typeof raw.deletedAt === 'number' && + Number.isFinite(raw.deletedAt) && + raw.deletedAt >= 0 && + raw.deletedAt <= now + ? raw.deletedAt + : now - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + if (now - deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) { + changed = true + continue + } const hostIds = new Set() for (const rawHostId of Array.isArray(raw.hostIds) ? raw.hostIds : []) { const hostId = normalizeExecutionHostId(rawHostId) @@ -2917,22 +2933,19 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( ) } } - const normalizedTombstone: DeletedFolderWorkspaceSessionTombstone = { + const normalizedTombstone = boundDeletedFolderTombstoneEvidence({ connectionId, + deletedAt, hostIds: [...hostIds], tabConnectionIdsByHostId - } + }) tombstones[workspaceKey as WorkspaceKey] = normalizedTombstone if (JSON.stringify(rawTombstone) !== JSON.stringify(normalizedTombstone)) { changed = true } } - const workspaceKeys = Object.keys(tombstones) - for (const workspaceKey of workspaceKeys.slice( - 0, - Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES) - )) { - delete tombstones[workspaceKey as WorkspaceKey] + for (const workspaceKey of getDeletedFolderTombstoneEvictionKeys(tombstones, now)) { + delete tombstones[workspaceKey] changed = true } return { tombstones, changed } @@ -3032,18 +3045,29 @@ export class Store { if (!tombstone) { continue } - if (tombstone.connectionId) { - this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId) + this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone) + } + } + + private indexDeletedFolderWorkspaceSessionTombstone( + workspaceKey: string, + tombstone: DeletedFolderWorkspaceSessionTombstone + ): void { + if (tombstone.connectionId) { + this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId) + } + for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) { + const resolvedHostId = normalizeExecutionHostId(hostId) + if (!resolvedHostId) { + continue } - for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) { - const resolvedHostId = normalizeExecutionHostId(hostId) - if (!resolvedHostId) { - continue - } - for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) { - this.rememberDeletedFolderTabOwner(resolvedHostId, tabId, workspaceKey, connectionId) - } + const ownersByTabId = this.deletedFolderOwnersByHostAndTabId.get(resolvedHostId) ?? new Map() + for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) { + const owners = ownersByTabId.get(tabId) ?? new Map() + owners.set(workspaceKey, connectionId) + ownersByTabId.set(tabId, owners) } + this.deletedFolderOwnersByHostAndTabId.set(resolvedHostId, ownersByTabId) } } @@ -4558,6 +4582,7 @@ export class Store { const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, { connectionId: connectionId ?? prior?.connectionId ?? null, + deletedAt: prior?.deletedAt ?? Date.now(), hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])], tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {} }) @@ -4692,18 +4717,21 @@ export class Store { this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId) const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId } - tabConnectionIdsByHostId[hostId] = { - ...tabConnectionIdsByHostId[hostId], - [tabId]: connectionId ?? tabConnectionIdsByHostId[hostId]?.[tabId] ?? null - } + const tabConnectionIds = { ...tabConnectionIdsByHostId[hostId] } + const retainedConnectionId = connectionId ?? tabConnectionIds[tabId] ?? null + delete tabConnectionIds[tabId] + tabConnectionIds[tabId] = retainedConnectionId + delete tabConnectionIdsByHostId[hostId] + tabConnectionIdsByHostId[hostId] = tabConnectionIds + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + const retainedHostIds = (prior?.hostIds ?? []).filter( + (retainedHostId) => retainedHostId !== hostId && retainedHostId !== connectionHostId + ) this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, { connectionId: prior?.connectionId ?? connectionId, + deletedAt: prior?.deletedAt ?? Date.now(), hostIds: [ - ...new Set([ - ...(prior?.hostIds ?? []), - hostId, - ...(connectionId ? [toSshExecutionHostId(connectionId)] : []) - ]) + ...new Set([...retainedHostIds, hostId, ...(connectionHostId ? [connectionHostId] : [])]) ], tabConnectionIdsByHostId }) @@ -4713,20 +4741,37 @@ export class Store { workspaceKey: string, tombstone: DeletedFolderWorkspaceSessionTombstone ): void { + tombstone = boundDeletedFolderTombstoneEvidence(tombstone) const tombstones = { ...this.state.deletedFolderWorkspaceSessionTombstones } delete tombstones[workspaceKey as WorkspaceKey] tombstones[workspaceKey as WorkspaceKey] = tombstone - const workspaceKeys = Object.keys(tombstones) - for (const evictedWorkspaceKey of workspaceKeys.slice( - 0, - Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES) + this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey) + this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone) + for (const evictedWorkspaceKey of getDeletedFolderTombstoneEvictionKeys( + tombstones, + Date.now() )) { - delete tombstones[evictedWorkspaceKey as WorkspaceKey] + delete tombstones[evictedWorkspaceKey] this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey) } this.state.deletedFolderWorkspaceSessionTombstones = tombstones } + private pruneDeletedFolderWorkspaceSessionTombstones(): void { + const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {} + const evictedWorkspaceKeys = getDeletedFolderTombstoneEvictionKeys(current, Date.now()) + if (evictedWorkspaceKeys.length === 0) { + return + } + const tombstones = { ...current } + for (const workspaceKey of evictedWorkspaceKeys) { + delete tombstones[workspaceKey] + this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey) + } + this.state.deletedFolderWorkspaceSessionTombstones = tombstones + this.scheduleSave() + } + private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void { this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey) for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) { @@ -4858,6 +4903,7 @@ export class Store { } private isDeletedFolderWorkspaceKey(worktreeId: string): boolean { + this.pruneDeletedFolderWorkspaceSessionTombstones() const scope = parseWorkspaceKey(worktreeId) return Boolean( scope?.type === 'folder' && @@ -5057,6 +5103,7 @@ export class Store { session: WorkspaceSessionState, hostId: ExecutionHostId ): WorkspaceSessionState { + this.pruneDeletedFolderWorkspaceSessionTombstones() const liveFolderIds = new Set( (this.state.folderWorkspaces ?? []).map((workspace) => workspace.id) ) diff --git a/src/main/runtime/mobile-folder-session-mutation-race.test.ts b/src/main/runtime/mobile-folder-session-mutation-race.test.ts new file mode 100644 index 00000000000..4aaecf7be26 --- /dev/null +++ b/src/main/runtime/mobile-folder-session-mutation-race.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { FolderWorkspace, ProjectGroup, WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }, + ipcMain, + Notification: vi.fn(), + webContents: { fromId: vi.fn(() => null) } + } +}) + +vi.mock('electron', () => electronMocks) + +const TAB_ID = '11111111-1111-4111-8111-111111111111' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' + +function makeSession(worktreeId: string): WorkspaceSessionState { + const ptyId = `${worktreeId}@@race-pty` + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: TAB_ID, + activeTabIdByWorktree: { [worktreeId]: TAB_ID }, + tabsByWorktree: { + [worktreeId]: [ + { + id: TAB_ID, + ptyId, + worktreeId, + title: 'Persisted terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + } + }, + terminalPtyIncarnationsByPaneKey: { + [makePaneKey(TAB_ID, LEAF_ID)]: '33333333-3333-4333-8333-333333333333' + } + } +} + +function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRuntimeService { + const session = makeSession(`folder:${folders[0]!.id}`) + return new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + getFolderWorkspaces: () => folders, + getProjectGroups: () => [group], + getWorkspaceSession: () => session, + getWorkspaceSessionHostIds: () => ['local'], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/workspace', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } as never) +} + +describe('mobile folder session mutation races', () => { + it.each(['activate', 'close'] as const)( + 'rejects a folder tab %s when deletion races PTY inventory', + async (operation) => { + const folder: FolderWorkspace = { + id: `${operation}-race-folder`, + projectGroupId: `${operation}-race-group`, + name: `${operation} race`, + folderPath: `/workspace/${operation}-race`, + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + const group: ProjectGroup = { + id: folder.projectGroupId, + name: folder.projectGroupId, + parentPath: folder.folderPath, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderKey = `folder:${folder.id}` + const folders = [folder] + const runtime = createRuntime(folders, group) + await runtime.listMobileSessionTabs(`id:${folderKey}`) + let releaseInventory!: (value: []) => void + const inventory = new Promise<[]>((resolve) => { + releaseInventory = resolve + }) + const listProcesses = vi.fn(() => inventory) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses + }) + + const mutation = + operation === 'activate' + ? runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID) + : runtime.closeMobileSessionTab(`id:${folderKey}`, TAB_ID) + await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledOnce()) + folders.length = 0 + releaseInventory([]) + + await expect(mutation).rejects.toThrow('tab_not_found') + await expect(runtime.listMobileSessionTabs(`id:${folderKey}`)).resolves.toMatchObject({ + worktree: folderKey, + removed: true, + tabs: [] + }) + } + ) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index de6e70782b0..c737a0acde7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4920,6 +4920,20 @@ export class OrcaRuntimeService { return recreatedFolderWorktreeIds } + private assertMobileSessionFolderWorkspaceExists(worktreeId: string): void { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type !== 'folder' || !this.store?.getFolderWorkspaces) { + return + } + const folderWorkspaces = this.store.getFolderWorkspaces() + this.pruneRemovedFolderMobileSessionTabs(folderWorkspaces) + if (folderWorkspaces.some((workspace) => workspace.id === scope.folderWorkspaceId)) { + return + } + this.rememberRemovedFolderMobileSessionTabs(worktreeId) + throw new Error('tab_not_found') + } + private createFolderWorkspaceInventoryFence( folderWorkspaces: readonly FolderWorkspace[] ): () => boolean { @@ -8006,6 +8020,7 @@ export class OrcaRuntimeService { explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) await this.refreshMobileSessionPtyRecords(worktreeId) + this.assertMobileSessionFolderWorkspaceExists(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) const tab = leafId @@ -8350,6 +8365,7 @@ export class OrcaRuntimeService { explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() + this.assertMobileSessionFolderWorkspaceExists(worktreeId) if (graphEpoch !== null) { this.assertStableReadyGraph(graphEpoch) } diff --git a/src/shared/types.ts b/src/shared/types.ts index 64cce8a40e7..b231a7a4a67 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3566,6 +3566,7 @@ export type PersistedMobileClientTabSelections = Record< export type DeletedFolderWorkspaceSessionTombstone = { connectionId: string | null + deletedAt: number hostIds: ExecutionHostId[] tabConnectionIdsByHostId: Partial>> }