From 750fa4c425f430ff7bb9b908c673485fe9626c75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:30:38 -0700 Subject: [PATCH] fix(mobile): bound deleted folder session tombstones --- src/main/persistence.test.ts | 57 +++++++++++++++++++++++++++++++++ src/main/persistence.ts | 61 ++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 21aee933645..54ac8b3d4a1 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5176,6 +5176,63 @@ describe('Store', () => { }) }) + it('keeps only the newest deleted-folder tombstones when loading older state', async () => { + const initial = await createStore() + initial.flush() + const state = readDataFile() as PersistedState + const workspaceKeys = Array.from({ length: 514 }, (_, index) => + folderWorkspaceKey(`deleted-${index}`) + ) + state.deletedFolderWorkspaceSessionTombstones = Object.fromEntries( + workspaceKeys.map((workspaceKey) => [ + workspaceKey, + { connectionId: null, hostIds: ['local'], tabConnectionIdsByHostId: {} } + ]) + ) as PersistedState['deletedFolderWorkspaceSessionTombstones'] + writeDataFile(state) + + vi.useFakeTimers() + try { + const restored = await createStore() + await vi.advanceTimersByTimeAsync(1_000) + await restored.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + + 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() + }) + + it('bounds deleted-folder tombstones during repeated workspace churn', 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) + } + + 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() + }) + it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 10e0a5cefd7..2a9d9e527d4 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -567,6 +567,7 @@ const WORKSPACE_SESSION_PATCH_OWNER_KEYS = new Set( 'terminalSurfaceTombstonesByPaneKey', 'activeWorkspaceExecutionHostId' ]) +const MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES = 512 function logPersistenceStartupMilestone( event: string, @@ -2926,6 +2927,14 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( 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] + changed = true + } return { tombstones, changed } } @@ -4546,14 +4555,12 @@ export class Store { hostIds.add(hostId) } } - const tombstones = { ...this.state.deletedFolderWorkspaceSessionTombstones } - const prior = tombstones[workspaceKey] - tombstones[workspaceKey] = { + const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] + this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, { connectionId: connectionId ?? prior?.connectionId ?? null, hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])], tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {} - } - this.state.deletedFolderWorkspaceSessionTombstones = tombstones + }) for (const { hostId, session } of partitions) { const tabIds = new Set() const tabPtyIdByTabId = new Map() @@ -4683,14 +4690,13 @@ export class Store { owners.set(workspaceKey, connectionId ?? owners.get(workspaceKey) ?? null) ownersByTabId.set(tabId, owners) this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId) - const tombstones = { ...this.state.deletedFolderWorkspaceSessionTombstones } - const prior = tombstones[workspaceKey] + const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId } tabConnectionIdsByHostId[hostId] = { ...tabConnectionIdsByHostId[hostId], [tabId]: connectionId ?? tabConnectionIdsByHostId[hostId]?.[tabId] ?? null } - tombstones[workspaceKey] = { + this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, { connectionId: prior?.connectionId ?? connectionId, hostIds: [ ...new Set([ @@ -4700,10 +4706,42 @@ export class Store { ]) ], tabConnectionIdsByHostId + }) + } + + private setDeletedFolderWorkspaceSessionTombstone( + workspaceKey: string, + tombstone: DeletedFolderWorkspaceSessionTombstone + ): void { + 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) + )) { + delete tombstones[evictedWorkspaceKey as WorkspaceKey] + this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey) } this.state.deletedFolderWorkspaceSessionTombstones = tombstones } + private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void { + this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey) + for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) { + for (const [tabId, owners] of ownersByTabId) { + owners.delete(workspaceKey) + if (owners.size === 0) { + ownersByTabId.delete(tabId) + } + } + if (ownersByTabId.size === 0) { + this.deletedFolderOwnersByHostAndTabId.delete(hostId) + } + } + } + private removeSshRemotePtyLeasesForWorkspaceOwners(ownerKeys: ReadonlySet): void { if (ownerKeys.size === 0 || !this.state.sshRemotePtyLeases) { return @@ -4832,12 +4870,17 @@ export class Store { private getDeletedFolderConnectionIdsForHost(hostId: ExecutionHostId): Set { const connectionIds = new Set() + const liveFolderIds = new Set( + (this.state.folderWorkspaces ?? []).map((workspace) => workspace.id) + ) for (const [workspaceKey, tombstone] of Object.entries( this.state.deletedFolderWorkspaceSessionTombstones ?? {} )) { + const scope = parseWorkspaceKey(workspaceKey) if ( !tombstone || - !this.isDeletedFolderWorkspaceKey(workspaceKey) || + scope?.type !== 'folder' || + liveFolderIds.has(scope.folderWorkspaceId) || !tombstone.hostIds.includes(hostId) ) { continue