diff --git a/src/main/deleted-folder-session-tombstones.test.ts b/src/main/deleted-folder-session-tombstones.test.ts new file mode 100644 index 00000000000..f06039f9ed7 --- /dev/null +++ b/src/main/deleted-folder-session-tombstones.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + DeletedFolderWorkspaceSessionTombstone, + PersistedState, + WorkspaceKey +} from '../shared/types' +import { + addDeletedFolderTombstoneOverflowEntries, + getDeletedFolderTombstoneEviction, + hasDeletedFolderConnectionOverflowEvidence, + hasDeletedFolderTabOwnerOverflowEvidence, + hasDeletedFolderWorkspaceKeyOverflowEvidence, + pruneDeletedFolderTombstoneOverflowBuckets +} from './deleted-folder-session-tombstones' + +const NOW = Date.parse('2026-01-15T12:00:00Z') + +function tombstone( + deletedAt = NOW, + connectionId: string | null = null +): DeletedFolderWorkspaceSessionTombstone { + return { + connectionId, + deletedAt, + evidenceTruncated: false, + hostIds: ['local'], + tabConnectionIdsByHostId: {} + } +} + +describe('deleted folder session tombstones', () => { + it('does not sort an already-capped tombstone set', () => { + const tombstones = Object.fromEntries( + Array.from({ length: 512 }, (_, index) => [`folder:deleted-${index}`, tombstone(NOW - index)]) + ) as NonNullable + const sortSpy = vi.spyOn(Array.prototype, 'sort') + + const eviction = getDeletedFolderTombstoneEviction(tombstones, NOW) + + expect(eviction).toEqual({ workspaceKeys: [], overflowEntries: [] }) + expect(sortSpy).not.toHaveBeenCalled() + sortSpy.mockRestore() + }) + + it('keeps overflow evidence keyed to deleted workspace, tab, and connection identities', () => { + const workspaceKey = 'folder:deleted' as WorkspaceKey + const deleted = tombstone(NOW, 'deleted-connection') + deleted.hostIds = ['runtime:deleted-host'] + deleted.tabConnectionIdsByHostId = { + 'runtime:deleted-host': { 'deleted-tab': 'deleted-connection' } + } + + const buckets = addDeletedFolderTombstoneOverflowEntries( + undefined, + [{ workspaceKey, tombstone: deleted }], + NOW + ) + + expect(hasDeletedFolderWorkspaceKeyOverflowEvidence(buckets, workspaceKey, NOW)).toBe(true) + expect( + hasDeletedFolderTabOwnerOverflowEvidence(buckets, 'runtime:deleted-host', 'deleted-tab', NOW) + ).toBe(true) + expect(hasDeletedFolderConnectionOverflowEvidence(buckets, 'deleted-connection', NOW)).toBe( + true + ) + expect(hasDeletedFolderWorkspaceKeyOverflowEvidence(buckets, 'folder:unrelated', NOW)).toBe( + false + ) + expect( + hasDeletedFolderTabOwnerOverflowEvidence( + buckets, + 'runtime:unrelated-host', + 'unrelated-tab', + NOW + ) + ).toBe(false) + expect(hasDeletedFolderConnectionOverflowEvidence(buckets, 'unrelated-connection', NOW)).toBe( + false + ) + }) + + it('expires overflow buckets without cloning active evidence', () => { + const buckets = addDeletedFolderTombstoneOverflowEntries( + undefined, + [{ workspaceKey: 'folder:deleted' as WorkspaceKey, tombstone: tombstone() }], + NOW + ) + + expect(pruneDeletedFolderTombstoneOverflowBuckets(buckets, NOW)).toBe(buckets) + expect( + pruneDeletedFolderTombstoneOverflowBuckets(buckets, NOW + 30 * 24 * 60 * 60 * 1000) + ).toEqual([]) + }) +}) diff --git a/src/main/deleted-folder-session-tombstones.ts b/src/main/deleted-folder-session-tombstones.ts index 3fd88d3fb98..34c2621caba 100644 --- a/src/main/deleted-folder-session-tombstones.ts +++ b/src/main/deleted-folder-session-tombstones.ts @@ -1,14 +1,33 @@ +import { createHash } from 'node:crypto' import type { ExecutionHostId } from '../shared/execution-host' import type { DeletedFolderWorkspaceSessionTombstone, + DeletedFolderWorkspaceSessionTombstoneOverflowBucket, PersistedState, WorkspaceKey } from '../shared/types' const MAX_TOMBSTONES = 512 export const MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 +// Why: daily keyed filters bound churn without turning unrelated remote folder identities into deletions. +export const DELETED_FOLDER_OVERFLOW_BUCKET_MS = 24 * 60 * 60 * 1000 +export const MAX_DELETED_FOLDER_OVERFLOW_BUCKETS = 31 +export const DELETED_FOLDER_OVERFLOW_FILTER_BYTES = 8 * 1024 +const OVERFLOW_BUCKET_MS = DELETED_FOLDER_OVERFLOW_BUCKET_MS +const MAX_OVERFLOW_BUCKETS = MAX_DELETED_FOLDER_OVERFLOW_BUCKETS +const OVERFLOW_FILTER_BYTES = DELETED_FOLDER_OVERFLOW_FILTER_BYTES +const OVERFLOW_FILTER_HASHES = 4 const MAX_HOST_IDS = 32 const MAX_TAB_OWNERS = 256 +const overflowFilterCache = new WeakMap< + DeletedFolderWorkspaceSessionTombstoneOverflowBucket, + Partial> +>() + +export type DeletedFolderTombstoneOverflowEntry = { + workspaceKey: WorkspaceKey + tombstone: DeletedFolderWorkspaceSessionTombstone +} export function boundDeletedFolderTombstoneEvidence( tombstone: DeletedFolderWorkspaceSessionTombstone @@ -49,7 +68,7 @@ export function boundDeletedFolderTombstoneEvidence( export function getDeletedFolderTombstoneEviction( tombstones: NonNullable, now: number -): { workspaceKeys: WorkspaceKey[]; overflowExpiresAt: number | undefined } { +): { workspaceKeys: WorkspaceKey[]; overflowEntries: DeletedFolderTombstoneOverflowEntry[] } { const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) => tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : [] ) @@ -59,23 +78,190 @@ export function getDeletedFolderTombstoneEviction( 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) - const capEvictions = retained - .sort((left, right) => - left.tombstone.deletedAt === right.tombstone.deletedAt - ? left.index - right.index - : left.tombstone.deletedAt - right.tombstone.deletedAt - ) - .slice(0, excess) - const overflowExpiresAt = capEvictions.reduce( - (latest, { tombstone }) => - Math.max(latest ?? 0, tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS), - undefined - ) + const capEvictions = + excess === 0 + ? [] + : retained + .sort((left, right) => + left.tombstone.deletedAt === right.tombstone.deletedAt + ? left.index - right.index + : left.tombstone.deletedAt - right.tombstone.deletedAt + ) + .slice(0, excess) return { workspaceKeys: [ ...expired.map(({ workspaceKey }) => workspaceKey), ...capEvictions.map(({ workspaceKey }) => workspaceKey) ], - overflowExpiresAt + overflowEntries: capEvictions.map(({ workspaceKey, tombstone }) => ({ + workspaceKey, + tombstone + })) } } + +function getOverflowFilterOffsets(value: string): number[] { + const digest = createHash('sha256').update(value).digest() + return Array.from( + { length: OVERFLOW_FILTER_HASHES }, + (_, index) => digest.readUInt32BE(index * 4) % (OVERFLOW_FILTER_BYTES * 8) + ) +} + +function addOverflowFilterValue(filter: Buffer, value: string): void { + for (const offset of getOverflowFilterOffsets(value)) { + filter[offset >> 3] |= 1 << (offset & 7) + } +} + +function overflowBucketFilterHasValue( + bucket: DeletedFolderWorkspaceSessionTombstoneOverflowBucket, + field: 'workspaceKeyBits' | 'tabOwnerBits' | 'connectionIdBits', + value: string +): boolean { + const cached = overflowFilterCache.get(bucket) ?? {} + let filter = cached[field] + if (filter === undefined) { + filter = decodeDeletedFolderOverflowFilter(bucket[field]) + cached[field] = filter + overflowFilterCache.set(bucket, cached) + } + if (!filter) { + return false + } + return getOverflowFilterOffsets(value).every( + (offset) => (filter[offset >> 3]! & (1 << (offset & 7))) !== 0 + ) +} + +export function decodeDeletedFolderOverflowFilter(value: unknown): Buffer | null { + if (typeof value !== 'string') { + return null + } + const decoded = Buffer.from(value, 'base64') + return decoded.length === OVERFLOW_FILTER_BYTES ? decoded : null +} + +export function addDeletedFolderTombstoneOverflowEntries( + current: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + entries: readonly DeletedFolderTombstoneOverflowEntry[], + now: number +): DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] { + const buckets = pruneDeletedFolderTombstoneOverflowBuckets(current, now) ?? [] + if (entries.length === 0) { + return buckets + } + const mutableByStart = new Map( + buckets.map((bucket) => [ + bucket.bucketStart, + { + ...bucket, + workspaceKeyFilter: Buffer.from(bucket.workspaceKeyBits, 'base64'), + tabOwnerFilter: bucket.tabOwnerBits + ? Buffer.from(bucket.tabOwnerBits, 'base64') + : Buffer.alloc(OVERFLOW_FILTER_BYTES), + connectionIdFilter: bucket.connectionIdBits + ? Buffer.from(bucket.connectionIdBits, 'base64') + : Buffer.alloc(OVERFLOW_FILTER_BYTES) + } + ]) + ) + for (const { workspaceKey, tombstone } of entries) { + const bucketStart = Math.floor(tombstone.deletedAt / OVERFLOW_BUCKET_MS) * OVERFLOW_BUCKET_MS + const bucket = mutableByStart.get(bucketStart) ?? { + bucketStart, + expiresAt: tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS, + workspaceKeyBits: '', + evidenceTruncated: false, + workspaceKeyFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES), + tabOwnerFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES), + connectionIdFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES) + } + addOverflowFilterValue(bucket.workspaceKeyFilter, workspaceKey) + if (tombstone.connectionId) { + addOverflowFilterValue(bucket.connectionIdFilter, tombstone.connectionId) + } + for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) { + for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) { + addOverflowFilterValue(bucket.tabOwnerFilter, `${hostId}\0${tabId}`) + if (connectionId) { + addOverflowFilterValue(bucket.connectionIdFilter, connectionId) + } + } + } + bucket.expiresAt = Math.max( + bucket.expiresAt, + tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + ) + bucket.evidenceTruncated ||= tombstone.evidenceTruncated + mutableByStart.set(bucketStart, bucket) + } + return [...mutableByStart.values()] + .sort((left, right) => left.bucketStart - right.bucketStart) + .slice(-MAX_OVERFLOW_BUCKETS) + .map(({ workspaceKeyFilter, tabOwnerFilter, connectionIdFilter, ...bucket }) => ({ + ...bucket, + workspaceKeyBits: workspaceKeyFilter.toString('base64'), + ...(tabOwnerFilter.some((byte) => byte !== 0) + ? { tabOwnerBits: tabOwnerFilter.toString('base64') } + : {}), + ...(connectionIdFilter.some((byte) => byte !== 0) + ? { connectionIdBits: connectionIdFilter.toString('base64') } + : {}) + })) +} + +export function pruneDeletedFolderTombstoneOverflowBuckets( + current: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + now: number +): DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined { + if (!current) { + return undefined + } + const retained = current.filter((bucket) => bucket.expiresAt > now) + return retained.length === current.length ? current : retained +} + +export function hasDeletedFolderWorkspaceKeyOverflowEvidence( + buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + workspaceKey: string, + now: number +): boolean { + return (buckets ?? []).some( + (bucket) => + bucket.expiresAt > now && + overflowBucketFilterHasValue(bucket, 'workspaceKeyBits', workspaceKey) + ) +} + +export function hasDeletedFolderTabOwnerOverflowEvidence( + buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + hostId: ExecutionHostId, + tabId: string, + now: number +): boolean { + return (buckets ?? []).some( + (bucket) => + bucket.expiresAt > now && + overflowBucketFilterHasValue(bucket, 'tabOwnerBits', `${hostId}\0${tabId}`) + ) +} + +export function hasDeletedFolderConnectionOverflowEvidence( + buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + connectionId: string, + now: number +): boolean { + return (buckets ?? []).some( + (bucket) => + bucket.expiresAt > now && + overflowBucketFilterHasValue(bucket, 'connectionIdBits', connectionId) + ) +} + +export function hasTruncatedDeletedFolderOverflowEvidence( + buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined, + now: number +): boolean { + return (buckets ?? []).some((bucket) => bucket.expiresAt > now && bucket.evidenceTruncated) +} diff --git a/src/main/deleted-folder-tombstone-overflow-normalization.ts b/src/main/deleted-folder-tombstone-overflow-normalization.ts new file mode 100644 index 00000000000..f1376b77677 --- /dev/null +++ b/src/main/deleted-folder-tombstone-overflow-normalization.ts @@ -0,0 +1,125 @@ +import type { DeletedFolderWorkspaceSessionTombstoneOverflowBucket } from '../shared/types' +import { + decodeDeletedFolderOverflowFilter, + DELETED_FOLDER_OVERFLOW_BUCKET_MS, + DELETED_FOLDER_OVERFLOW_FILTER_BYTES, + MAX_DELETED_FOLDER_OVERFLOW_BUCKETS, + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS +} from './deleted-folder-session-tombstones' + +function mergeOverflowFilter(target: Buffer, encodedSource: string | undefined): void { + if (!encodedSource) { + return + } + const source = Buffer.from(encodedSource, 'base64') + if (source.length !== DELETED_FOLDER_OVERFLOW_FILTER_BYTES) { + return + } + for (let index = 0; index < target.length; index += 1) { + target[index] |= source[index]! + } +} + +export function normalizeDeletedFolderTombstoneOverflowBuckets( + value: unknown, + now: number +): { + buckets: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] + changed: boolean +} { + if (!Array.isArray(value)) { + return { buckets: [], changed: value !== undefined } + } + const bucketsByStart = new Map< + number, + { + bucket: DeletedFolderWorkspaceSessionTombstoneOverflowBucket + workspaceKeyFilter: Buffer + tabOwnerFilter: Buffer | null + connectionIdFilter: Buffer | null + } + >() + let changed = false + for (const candidate of value) { + const raw = candidate as Partial | null + const workspaceKeyFilter = decodeDeletedFolderOverflowFilter(raw?.workspaceKeyBits) + if ( + !raw || + typeof raw.bucketStart !== 'number' || + !Number.isFinite(raw.bucketStart) || + typeof raw.expiresAt !== 'number' || + !Number.isFinite(raw.expiresAt) || + raw.expiresAt <= now || + !workspaceKeyFilter + ) { + changed = true + continue + } + const bucketStart = + Math.floor(raw.bucketStart / DELETED_FOLDER_OVERFLOW_BUCKET_MS) * + DELETED_FOLDER_OVERFLOW_BUCKET_MS + const expiresAt = Math.min( + raw.expiresAt, + bucketStart + DELETED_FOLDER_OVERFLOW_BUCKET_MS + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + ) + const tabOwnerFilter = raw.tabOwnerBits + ? decodeDeletedFolderOverflowFilter(raw.tabOwnerBits) + : null + const connectionIdFilter = raw.connectionIdBits + ? decodeDeletedFolderOverflowFilter(raw.connectionIdBits) + : null + if ( + bucketStart !== raw.bucketStart || + expiresAt !== raw.expiresAt || + workspaceKeyFilter.toString('base64') !== raw.workspaceKeyBits || + Boolean(raw.tabOwnerBits) !== Boolean(tabOwnerFilter) || + Boolean(raw.connectionIdBits) !== Boolean(connectionIdFilter) || + typeof raw.evidenceTruncated !== 'boolean' + ) { + changed = true + } + if (bucketStart > now || expiresAt <= now) { + changed = true + continue + } + const existing = bucketsByStart.get(bucketStart) + if (existing) { + mergeOverflowFilter(existing.workspaceKeyFilter, workspaceKeyFilter.toString('base64')) + if (tabOwnerFilter) { + existing.tabOwnerFilter ??= Buffer.alloc(DELETED_FOLDER_OVERFLOW_FILTER_BYTES) + mergeOverflowFilter(existing.tabOwnerFilter, raw.tabOwnerBits) + } + if (connectionIdFilter) { + existing.connectionIdFilter ??= Buffer.alloc(DELETED_FOLDER_OVERFLOW_FILTER_BYTES) + mergeOverflowFilter(existing.connectionIdFilter, raw.connectionIdBits) + } + existing.bucket.expiresAt = Math.max(existing.bucket.expiresAt, expiresAt) + existing.bucket.evidenceTruncated ||= raw.evidenceTruncated === true + changed = true + continue + } + bucketsByStart.set(bucketStart, { + bucket: { + bucketStart, + expiresAt, + workspaceKeyBits: workspaceKeyFilter.toString('base64'), + ...(tabOwnerFilter ? { tabOwnerBits: tabOwnerFilter.toString('base64') } : {}), + ...(connectionIdFilter ? { connectionIdBits: connectionIdFilter.toString('base64') } : {}), + evidenceTruncated: raw.evidenceTruncated === true + }, + workspaceKeyFilter, + tabOwnerFilter, + connectionIdFilter + }) + } + const retained = [...bucketsByStart.values()] + .sort((left, right) => left.bucket.bucketStart - right.bucket.bucketStart) + .slice(-MAX_DELETED_FOLDER_OVERFLOW_BUCKETS) + .map(({ bucket, workspaceKeyFilter, tabOwnerFilter, connectionIdFilter }) => ({ + ...bucket, + workspaceKeyBits: workspaceKeyFilter.toString('base64'), + ...(tabOwnerFilter ? { tabOwnerBits: tabOwnerFilter.toString('base64') } : {}), + ...(connectionIdFilter ? { connectionIdBits: connectionIdFilter.toString('base64') } : {}) + })) + return { buckets: retained, changed: changed || retained.length !== value.length } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 274db2eb7ed..72346ccdb2f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5216,9 +5216,10 @@ describe('Store', () => { expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined() expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined() - expect( - (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt - ).toBe(deletedAt + 1 + 30 * 24 * 60 * 60 * 1000) + const overflowBuckets = (readDataFile() as PersistedState) + .deletedFolderWorkspaceSessionTombstoneOverflowBuckets + expect(overflowBuckets).toHaveLength(1) + expect(overflowBuckets?.[0]?.expiresAt).toBe(deletedAt + 1 + 30 * 24 * 60 * 60 * 1000) }) it('hard-bounds recent deletion fences with conservative overflow evidence', async () => { @@ -5244,9 +5245,10 @@ describe('Store', () => { let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones expect(Object.keys(tombstones ?? {})).toHaveLength(512) expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() - expect( - (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt - ).toBeGreaterThan(Date.now()) + const overflowBuckets = (readDataFile() as PersistedState) + .deletedFolderWorkspaceSessionTombstoneOverflowBuckets + expect(overflowBuckets).toHaveLength(1) + expect(overflowBuckets?.[0]?.expiresAt).toBeGreaterThan(Date.now()) vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z')) const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' }) @@ -5275,7 +5277,14 @@ describe('Store', () => { tabConnectionIdsByHostId: {} } } - state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Date.now() - 1 + state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = [ + { + bucketStart: Date.now() - 31 * 24 * 60 * 60 * 1000, + expiresAt: Date.now() - 24 * 60 * 60 * 1000, + workspaceKeyBits: Buffer.alloc(8 * 1024).toString('base64'), + evidenceTruncated: false + } + ] writeDataFile(state) vi.useFakeTimers() @@ -5289,7 +5298,7 @@ describe('Store', () => { const persisted = readDataFile() as PersistedState expect(persisted.deletedFolderWorkspaceSessionTombstones).toEqual({}) - expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeUndefined() + expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets).toBeUndefined() }) it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { @@ -6875,9 +6884,25 @@ describe('Store', () => { } const persisted = (store as unknown as { state: PersistedState }).state expect(persisted.deletedFolderWorkspaceSessionTombstones?.[workspaceKey]).toBeUndefined() - expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeGreaterThan( - Date.now() + expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets).toHaveLength(1) + expect( + persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets?.[0]?.expiresAt + ).toBeGreaterThan(Date.now()) + + store.patchWorkspaceSession( + { + terminalLayoutsByTabId: { + 'rapid-churn-stale-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'rapid-churn-stale-pty' } + } + } + }, + hostId ) + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId).toEqual({}) store.setWorkspaceSession(staleSession, hostId) store.upsertSshRemotePtyLease({ @@ -6894,6 +6919,57 @@ describe('Store', () => { expect(store.getSshRemotePtyLeases()).toEqual([]) }) + it('preserves unrelated runtime-owned folders through deletion overflow', async () => { + const store = await createStore() + vi.useFakeTimers() + try { + const group = store.createProjectGroup({ + name: 'Unrelated churn', + parentPath: '/workspace/unrelated-churn', + createdFrom: 'folder-scan' + }) + for (let index = 0; index < 513; index += 1) { + const workspace = store.createFolderWorkspace({ + projectGroupId: group.id, + name: `Unrelated ${index}` + }) + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + } + const hostId = toRuntimeExecutionHostId('unrelated-runtime-folder') + const workspaceKey = 'folder:runtime-owned-without-local-catalog' + const connectionId = 'unrelated-runtime-connection' + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + activeConnectionIdsAtShutdown: [connectionId], + tabsByWorktree: { + [workspaceKey]: [ + makeTerminalTab({ id: 'unrelated-runtime-tab', worktreeId: workspaceKey }) + ] + }, + remoteSessionIdsByTabId: { 'unrelated-runtime-tab': 'unrelated-runtime-pty' } + }, + hostId + ) + store.upsertSshRemotePtyLease({ + targetId: connectionId, + ptyId: 'unrelated-runtime-pty', + worktreeId: workspaceKey, + tabId: 'unrelated-runtime-tab', + leafId: TEST_LEAF_1, + state: 'detached' + }) + + expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toHaveLength(1) + expect(store.getWorkspaceSession(hostId).activeConnectionIdsAtShutdown).toEqual([ + connectionId + ]) + expect(store.getSshRemotePtyLeases()).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it('batches deletion evidence commits for folders with many tabs', async () => { const store = await createStore() const group = store.createProjectGroup({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ae8ee163337..a5f151fa06e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -89,10 +89,17 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-lin import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { + addDeletedFolderTombstoneOverflowEntries, boundDeletedFolderTombstoneEvidence, getDeletedFolderTombstoneEviction, + hasDeletedFolderConnectionOverflowEvidence, + hasDeletedFolderTabOwnerOverflowEvidence, + hasDeletedFolderWorkspaceKeyOverflowEvidence, + hasTruncatedDeletedFolderOverflowEvidence, + pruneDeletedFolderTombstoneOverflowBuckets, MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS } from './deleted-folder-session-tombstones' +import { normalizeDeletedFolderTombstoneOverflowBuckets } from './deleted-folder-tombstone-overflow-normalization' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { @@ -2865,26 +2872,22 @@ function backfillFolderScopeConnectionIds(state: PersistedState): { function normalizeDeletedFolderWorkspaceSessionTombstones( value: unknown, liveFolderIds: ReadonlySet, - rawOverflowExpiresAt: unknown + rawOverflowBuckets: unknown ): { tombstones: NonNullable - overflowExpiresAt: number | undefined + overflowBuckets: NonNullable< + PersistedState['deletedFolderWorkspaceSessionTombstoneOverflowBuckets'] + > changed: boolean } { const now = Date.now() const tombstones: NonNullable = {} - let changed = value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value)) - let overflowExpiresAt = - typeof rawOverflowExpiresAt === 'number' && - Number.isFinite(rawOverflowExpiresAt) && - rawOverflowExpiresAt > now - ? Math.min(rawOverflowExpiresAt, now + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) - : undefined - if (rawOverflowExpiresAt !== undefined && overflowExpiresAt !== rawOverflowExpiresAt) { - changed = true - } + const normalizedOverflow = normalizeDeletedFolderTombstoneOverflowBuckets(rawOverflowBuckets, now) + let changed = + normalizedOverflow.changed || + (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))) if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { tombstones, overflowExpiresAt, changed } + return { tombstones, overflowBuckets: normalizedOverflow.buckets, changed } } for (const [workspaceKey, rawTombstone] of Object.entries(value)) { const scope = parseWorkspaceKey(workspaceKey) @@ -2960,8 +2963,12 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( delete tombstones[workspaceKey] changed = true } - overflowExpiresAt = Math.max(overflowExpiresAt ?? 0, eviction.overflowExpiresAt ?? 0) || undefined - return { tombstones, overflowExpiresAt, changed } + const overflowBuckets = addDeletedFolderTombstoneOverflowEntries( + normalizedOverflow.buckets, + eviction.overflowEntries, + now + ) + return { tombstones, overflowBuckets, changed } } function deleteRemovedTerminalScrollbackSnapshots( @@ -3495,7 +3502,7 @@ export class Store { const normalizedDeletedFolderTombstones = normalizeDeletedFolderWorkspaceSessionTombstones( parsed.deletedFolderWorkspaceSessionTombstones, new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id)), - parsed.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + parsed.deletedFolderWorkspaceSessionTombstoneOverflowBuckets ) if (normalizedDeletedFolderTombstones.changed) { this.loadNeedsSave = true @@ -3550,8 +3557,10 @@ export class Store { projectGroups: normalizedProjectGroups, folderWorkspaces: normalizedFolderWorkspaces, deletedFolderWorkspaceSessionTombstones: normalizedDeletedFolderTombstones.tombstones, - deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt: - normalizedDeletedFolderTombstones.overflowExpiresAt, + deletedFolderWorkspaceSessionTombstoneOverflowBuckets: + normalizedDeletedFolderTombstones.overflowBuckets.length > 0 + ? normalizedDeletedFolderTombstones.overflowBuckets + : undefined, worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( parsed.mobileClientTabSelectionsByDeviceId @@ -4795,11 +4804,15 @@ export class Store { delete tombstones[evictedWorkspaceKey] this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey) } - if (eviction.overflowExpiresAt) { - this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Math.max( - this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0, - eviction.overflowExpiresAt - ) + const overflowBuckets = addDeletedFolderTombstoneOverflowEntries( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets, + eviction.overflowEntries, + Date.now() + ) + if (overflowBuckets.length > 0) { + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = overflowBuckets + } else { + delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets } this.state.deletedFolderWorkspaceSessionTombstones = tombstones } @@ -4808,12 +4821,13 @@ export class Store { const now = Date.now() const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {} const eviction = getDeletedFolderTombstoneEviction(current, now) - const overflowExpiresAt = this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt - const overflowExpired = overflowExpiresAt !== undefined && overflowExpiresAt <= now - const overflowExtended = - eviction.overflowExpiresAt !== undefined && - eviction.overflowExpiresAt > (overflowExpiresAt ?? 0) - if (eviction.workspaceKeys.length === 0 && !overflowExpired && !overflowExtended) { + const currentOverflow = this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets + const prunedOverflow = pruneDeletedFolderTombstoneOverflowBuckets(currentOverflow, now) + if ( + eviction.workspaceKeys.length === 0 && + eviction.overflowEntries.length === 0 && + prunedOverflow === currentOverflow + ) { return } const tombstones = { ...current } @@ -4821,18 +4835,26 @@ export class Store { delete tombstones[workspaceKey] this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey) } - if (overflowExtended) { - this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = - eviction.overflowExpiresAt - } else if (overflowExpired) { - delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + const overflowBuckets = addDeletedFolderTombstoneOverflowEntries( + prunedOverflow, + eviction.overflowEntries, + now + ) + if (overflowBuckets.length > 0) { + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = overflowBuckets + } else { + delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets } this.state.deletedFolderWorkspaceSessionTombstones = tombstones this.scheduleSave() } - private hasDeletedFolderWorkspaceSessionTombstoneOverflow(): boolean { - return (this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0) > Date.now() + private hasDeletedFolderWorkspaceKeyOverflowEvidence(workspaceKey: string): boolean { + return hasDeletedFolderWorkspaceKeyOverflowEvidence( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets, + workspaceKey, + Date.now() + ) } private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void { @@ -4967,6 +4989,10 @@ export class Store { private isDeletedFolderWorkspaceKey(worktreeId: string): boolean { this.pruneDeletedFolderWorkspaceSessionTombstones() + return this.isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId) + } + + private isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId: string): boolean { const scope = parseWorkspaceKey(worktreeId) return Boolean( scope?.type === 'folder' && @@ -4974,7 +5000,7 @@ export class Store { (workspace) => workspace.id === scope.folderWorkspaceId ) && (this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey] || - this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()) + this.hasDeletedFolderWorkspaceKeyOverflowEvidence(worktreeId)) ) } @@ -5017,9 +5043,18 @@ export class Store { if (!originalConnectionIds) { return session } - const removalCandidates = this.hasDeletedFolderWorkspaceSessionTombstoneOverflow() - ? new Set(originalConnectionIds) - : removedConnectionIds + const removalCandidates = new Set(removedConnectionIds) + for (const connectionId of originalConnectionIds) { + if ( + hasDeletedFolderConnectionOverflowEvidence( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets, + connectionId, + Date.now() + ) + ) { + removalCandidates.add(connectionId) + } + } if (removalCandidates.size === 0) { return session } @@ -5030,7 +5065,8 @@ export class Store { const retainedConnectionIds = this.collectWorkspaceSessionRemoteConnectionIds( retainedPartitions, (worktreeId) => - !removedOwnerKeys.has(worktreeId) && !this.isDeletedFolderWorkspaceKey(worktreeId) + !removedOwnerKeys.has(worktreeId) && + !this.isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId) ) for (const connectionId of this.collectCatalogConnectionIds(removedOwnerKeys)) { retainedConnectionIds.add(connectionId) @@ -5178,11 +5214,12 @@ export class Store { (this.state.folderWorkspaces ?? []).map((workspace) => workspace.id) ) const deletedFolderTombstones = this.state.deletedFolderWorkspaceSessionTombstones ?? {} - const hasDeletedFolderTombstoneOverflow = - this.hasDeletedFolderWorkspaceSessionTombstoneOverflow() const hasTruncatedDeletedFolderEvidence = - hasDeletedFolderTombstoneOverflow || - Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated) + Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated) || + hasTruncatedDeletedFolderOverflowEvidence( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets, + Date.now() + ) const ownerKeys = new Set() const ownerKeysByTabId = new Map>() const directPtyIdsByOwnerAndTabId = new Map>() @@ -5221,7 +5258,7 @@ export class Store { !liveFolderIds.has(scope.folderWorkspaceId) && (hostId === LOCAL_EXECUTION_HOST_ID || tombstone !== undefined || - hasDeletedFolderTombstoneOverflow) + this.hasDeletedFolderWorkspaceKeyOverflowEvidence(worktreeId)) ) { ownerKeys.add(worktreeId) } @@ -5315,9 +5352,18 @@ export class Store { collectOwnerKey(workspaceKey) } } - const hasUnownedTruncatedTabState = - hasTruncatedDeletedFolderEvidence && - [...tabScopedStateIds].some((tabId) => (ownerKeysByTabId.get(tabId)?.size ?? 0) === 0) + const hasOverflowTabOwner = (tabId: string): boolean => + hasDeletedFolderTabOwnerOverflowEvidence( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets, + hostId, + tabId, + Date.now() + ) + const hasUnownedTruncatedTabState = [...tabScopedStateIds].some( + (tabId) => + (ownerKeysByTabId.get(tabId)?.size ?? 0) === 0 && + (hasTruncatedDeletedFolderEvidence || hasOverflowTabOwner(tabId)) + ) if ( !session.activeWorkspaceKey && !session.activeWorktreeId && @@ -5390,7 +5436,7 @@ export class Store { const deletedOwner = [...(currentDeletedOwnersByTabId?.get(tabId)?.entries() ?? [])].find( ([workspaceKey]) => ownerKeys.has(workspaceKey) ) - if (!deletedOwner && !hasTruncatedDeletedFolderEvidence) { + if (!deletedOwner && !hasTruncatedDeletedFolderEvidence && !hasOverflowTabOwner(tabId)) { continue } exclusivelyDeletedTabIds.add(tabId) diff --git a/src/shared/types.ts b/src/shared/types.ts index 5b99ae7eb00..cca7bbbbbda 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3572,6 +3572,15 @@ export type DeletedFolderWorkspaceSessionTombstone = { tabConnectionIdsByHostId: Partial>> } +export type DeletedFolderWorkspaceSessionTombstoneOverflowBucket = { + bucketStart: number + expiresAt: number + workspaceKeyBits: string + tabOwnerBits?: string + connectionIdBits?: string + evidenceTruncated: boolean +} + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number @@ -3583,7 +3592,7 @@ export type PersistedState = { deletedFolderWorkspaceSessionTombstones?: Partial< Record > - deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number + deletedFolderWorkspaceSessionTombstoneOverflowBuckets?: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] /** Sparse-checkout presets keyed by repoId. */ sparsePresetsByRepo: Record /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */