fix: key folder tombstone overflow evidence

This commit is contained in:
Brennan Benson
2026-07-31 20:01:06 -07:00
parent 4d62514ea5
commit 33386a7e2a
6 changed files with 611 additions and 75 deletions
@@ -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<PersistedState['deletedFolderWorkspaceSessionTombstones']>
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([])
})
})
+200 -14
View File
@@ -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<Record<'workspaceKeyBits' | 'tabOwnerBits' | 'connectionIdBits', Buffer | null>>
>()
export type DeletedFolderTombstoneOverflowEntry = {
workspaceKey: WorkspaceKey
tombstone: DeletedFolderWorkspaceSessionTombstone
}
export function boundDeletedFolderTombstoneEvidence(
tombstone: DeletedFolderWorkspaceSessionTombstone
@@ -49,7 +68,7 @@ export function boundDeletedFolderTombstoneEvidence(
export function getDeletedFolderTombstoneEviction(
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
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<number | undefined>(
(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)
}
@@ -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<DeletedFolderWorkspaceSessionTombstoneOverflowBucket> | 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 }
}
+86 -10
View File
@@ -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({
+96 -50
View File
@@ -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<string>,
rawOverflowExpiresAt: unknown
rawOverflowBuckets: unknown
): {
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>
overflowExpiresAt: number | undefined
overflowBuckets: NonNullable<
PersistedState['deletedFolderWorkspaceSessionTombstoneOverflowBuckets']
>
changed: boolean
} {
const now = Date.now()
const tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']> = {}
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<string>()
const ownerKeysByTabId = new Map<string, Set<string>>()
const directPtyIdsByOwnerAndTabId = new Map<string, Map<string, string>>()
@@ -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)
+10 -1
View File
@@ -3572,6 +3572,15 @@ export type DeletedFolderWorkspaceSessionTombstone = {
tabConnectionIdsByHostId: Partial<Record<ExecutionHostId, Record<string, string | null>>>
}
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<WorkspaceKey, DeletedFolderWorkspaceSessionTombstone>
>
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number
deletedFolderWorkspaceSessionTombstoneOverflowBuckets?: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[]
/** Sparse-checkout presets keyed by repoId. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */