fix(mobile): bound deleted folder session tombstones

This commit is contained in:
Brennan Benson
2026-07-31 18:30:38 -07:00
parent 4939160346
commit 750fa4c425
2 changed files with 109 additions and 9 deletions
+57
View File
@@ -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<typeof folderWorkspaceKey>[] = []
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,
+52 -9
View File
@@ -567,6 +567,7 @@ const WORKSPACE_SESSION_PATCH_OWNER_KEYS = new Set<keyof WorkspaceSessionState>(
'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<string>()
const tabPtyIdByTabId = new Map<string, string>()
@@ -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<string>): void {
if (ownerKeys.size === 0 || !this.state.sshRemotePtyLeases) {
return
@@ -4832,12 +4870,17 @@ export class Store {
private getDeletedFolderConnectionIdsForHost(hostId: ExecutionHostId): Set<string> {
const connectionIds = new Set<string>()
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