diff --git a/src/renderer/src/runtime/web-session-tabs-sync-scale-environment-teardown.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-scale-environment-teardown.test.ts new file mode 100644 index 00000000000..ea9aee5baa9 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-scale-environment-teardown.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { + acceptReplayedWebSessionTabsSnapshot, + clearWebSessionTabsTrackingForEnvironment, + decideWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' +import { + beginWebSessionTabsSnapshotRecovery, + recordAcceptedWebSessionTabsEnvironment, + recordReceivedWebSessionTabsRemoval, + recordReceivedWebSessionTabsSnapshot, + rememberHostTerminalTabCount, + trackWebSessionTabsWorktree +} from './web-session-tabs-sync/tracking' +import { confirmTrackedWebSessionTabsInventoryAbsence } from './web-session-tabs-sync/session-tabs-inventory-absence' +import { + latestSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsRemovalFenceByWorktree, + lastHostTerminalTabCountByWorktree, + replayableSessionTabsSnapshotByWorktree, + sessionTabsEnvironmentsByWorktree, + sessionTabsInventoryOmissionsByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRecoveryStateByWorktree, + trackedSessionTabsWorktreeIdsByEnvironment +} from './web-session-tabs-sync/state' +import { _getSessionTabsEnvironmentKeyIndexCountsForTest } from './web-session-tabs-sync/session-tabs-environment-key-index' + +vi.mock('../store', () => ({ + useAppStore: { setState: vi.fn(), getState: vi.fn(() => ({})) } +})) +vi.mock('./web-session-terminal-handle-events', () => ({ + queueAcceptedWebSessionTerminalSnapshot: vi.fn() +})) + +const ENVIRONMENTS = 20 +const WORKTREES = 50 + +function envId(index: number): string { + return `env-${String(index).padStart(4, '0')}` +} +function worktreeId(index: number): string { + return `repo::/Users/x/orca/worktrees/workspace-${String(index).padStart(4, '0')}` +} + +function snapshotFor(worktree: string, version: number): RuntimeMobileSessionTabsResult { + return { + worktree, + publicationEpoch: `epoch-${version}`, + snapshotVersion: version, + tabs: [{ type: 'terminal', id: 'tab-1' }], + groups: [], + tabBarOrder: [], + activeTabId: null + } as unknown as RuntimeMobileSessionTabsResult +} + +/** Populates every per-worktree map through the paths that maintain the key index. */ +function populate(environments: number, worktrees: number): void { + resetWebSessionTabsSnapshotFreshnessForTests() + for (let e = 0; e < environments; e += 1) { + const environmentId = envId(e) + for (let w = 0; w < worktrees; w += 1) { + const worktree = worktreeId(w) + const snapshot = snapshotFor(worktree, 1) + const frame = recordReceivedWebSessionTabsSnapshot(environmentId, snapshot) + decideWebSessionTabsSnapshot(snapshot, environmentId) + trackWebSessionTabsWorktree(environmentId, worktree) + rememberHostTerminalTabCount(environmentId, snapshot) + recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) + beginWebSessionTabsSnapshotRecovery(environmentId, worktree, frame) + confirmTrackedWebSessionTabsInventoryAbsence(environmentId, { + worktree, + freshness: { publicationEpoch: 'epoch-1', snapshotVersion: 1 } + }) + } + } +} + +/** Every map an environment teardown must reach without enumerating it. */ +const SWEPT_MAPS = [ + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + latestSessionTabsRemovalFenceByWorktree, + sessionTabsRecoveryStateByWorktree, + lastHostTerminalTabCountByWorktree, + sessionTabsInventoryOmissionsByWorktree, + sessionTabsEnvironmentsByWorktree +] + +function keysForEnvironment(environmentId: string): number { + let count = 0 + for (const map of [ + latestSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRecoveryStateByWorktree, + lastHostTerminalTabCountByWorktree, + sessionTabsInventoryOmissionsByWorktree + ]) { + for (const key of map.keys()) { + if (key.startsWith(`${environmentId}:`)) { + count += 1 + } + } + } + return count +} + +function environmentsNamingWorktrees(environmentId: string): number { + let count = 0 + for (const environments of sessionTabsEnvironmentsByWorktree.values()) { + if (environments.has(environmentId)) { + count += 1 + } + } + return count +} + +describe('clearWebSessionTabsTrackingForEnvironment at workspace scale', () => { + beforeEach(resetWebSessionTabsSnapshotFreshnessForTests) + + it('drains only the torn-down environment across every per-worktree map', () => { + populate(ENVIRONMENTS, WORKTREES) + const victim = envId(0) + const bystander = envId(1) + expect(keysForEnvironment(victim)).toBeGreaterThan(0) + const bystanderKeysBefore = keysForEnvironment(bystander) + + clearWebSessionTabsTrackingForEnvironment(victim) + + expect(keysForEnvironment(victim)).toBe(0) + expect(environmentsNamingWorktrees(victim)).toBe(0) + expect(trackedSessionTabsWorktreeIdsByEnvironment.has(victim)).toBe(false) + expect(keysForEnvironment(bystander)).toBe(bystanderKeysBefore) + expect(environmentsNamingWorktrees(bystander)).toBe(WORKTREES) + }) + + it('drains the environment key index that makes teardown linear', () => { + populate(ENVIRONMENTS, WORKTREES) + expect(_getSessionTabsEnvironmentKeyIndexCountsForTest()).toEqual({ + environments: ENVIRONMENTS, + worktrees: ENVIRONMENTS * WORKTREES + }) + + for (let e = 0; e < ENVIRONMENTS; e += 1) { + clearWebSessionTabsTrackingForEnvironment(envId(e)) + } + + expect(_getSessionTabsEnvironmentKeyIndexCountsForTest()).toEqual({ + environments: 0, + worktrees: 0 + }) + }) + + it('never enumerates a per-worktree map, so teardown cost tracks one environment', () => { + populate(ENVIRONMENTS, WORKTREES) + // Why: a wall clock cannot separate linear from quadratic on a loaded box. + // Enumerating any of these maps is the sweep itself, so count that instead. + const enumerations = SWEPT_MAPS.map((map) => vi.spyOn(map, 'keys')) + + clearWebSessionTabsTrackingForEnvironment(envId(0)) + + for (const enumeration of enumerations) { + expect(enumeration).not.toHaveBeenCalled() + enumeration.mockRestore() + } + expect(keysForEnvironment(envId(0))).toBe(0) + }) +}) + +/** + * Every write path must register the key index, or teardown silently strands + * that map's entries. Each case populates through exactly one path. + */ +const WRITE_PATHS: readonly { + name: string + write: (environmentId: string, worktree: string) => void + size: () => number +}[] = [ + { + name: 'received snapshot', + write: (environmentId, worktree) => { + recordReceivedWebSessionTabsSnapshot(environmentId, snapshotFor(worktree, 1)) + }, + size: () => latestReceivedSessionTabsSnapshotByWorktree.size + }, + { + name: 'publication epoch tombstone', + write: (environmentId, worktree) => { + recordReceivedWebSessionTabsSnapshot(environmentId, snapshotFor(worktree, 1)) + }, + size: () => sessionTabsPublicationEpochHistoryByWorktree.size + }, + { + name: 'accepted snapshot', + write: (environmentId, worktree) => { + decideWebSessionTabsSnapshot(snapshotFor(worktree, 1), environmentId) + }, + size: () => latestSessionTabsSnapshotByWorktree.size + }, + { + name: 'replayed snapshot', + write: (environmentId, worktree) => { + decideWebSessionTabsSnapshot(snapshotFor(worktree, 1), environmentId) + acceptReplayedWebSessionTabsSnapshot(environmentId, worktree) + }, + size: () => replayableSessionTabsSnapshotByWorktree.size + }, + { + name: 'pending recovery', + write: (environmentId, worktree) => { + beginWebSessionTabsSnapshotRecovery(environmentId, worktree, 1) + }, + size: () => sessionTabsRecoveryStateByWorktree.size + }, + { + name: 'removal fence', + write: (environmentId, worktree) => { + beginWebSessionTabsSnapshotRecovery(environmentId, worktree, 1) + recordReceivedWebSessionTabsRemoval(environmentId, worktree, 2) + }, + size: () => latestSessionTabsRemovalFenceByWorktree.size + }, + { + name: 'host terminal tab count', + write: (environmentId, worktree) => { + rememberHostTerminalTabCount(environmentId, snapshotFor(worktree, 1)) + }, + size: () => lastHostTerminalTabCountByWorktree.size + }, + { + name: 'inventory omission', + write: (environmentId, worktree) => { + confirmTrackedWebSessionTabsInventoryAbsence(environmentId, { + worktree, + freshness: { publicationEpoch: 'epoch-1', snapshotVersion: 1 } + }) + }, + size: () => sessionTabsInventoryOmissionsByWorktree.size + }, + { + name: 'accepted environment', + write: (environmentId, worktree) => { + recordAcceptedWebSessionTabsEnvironment(environmentId, snapshotFor(worktree, 1)) + }, + size: () => sessionTabsEnvironmentsByWorktree.size + } +] + +describe.each(WRITE_PATHS)('teardown after a $name write', ({ write, size }) => { + it('leaves nothing behind', () => { + resetWebSessionTabsSnapshotFreshnessForTests() + for (let w = 0; w < WORKTREES; w += 1) { + write(envId(0), worktreeId(w)) + } + expect(size()).toBeGreaterThan(0) + + clearWebSessionTabsTrackingForEnvironment(envId(0)) + + expect(size()).toBe(0) + expect(_getSessionTabsEnvironmentKeyIndexCountsForTest().worktrees).toBe(0) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-scale-long-session-growth.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-scale-long-session-growth.test.ts new file mode 100644 index 00000000000..b6c9444800d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-scale-long-session-growth.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { + decideWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' +import { clearWebSessionTabsTrackingForWorktree } from './web-session-tabs-sync/tracking-lifecycle' +import { recordReceivedWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking' +import { + MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY, + lastHostTerminalTabCountByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + sessionTabsInventoryOmissionsByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRuntimeHistoryByEnvironment +} from './web-session-tabs-sync/state' +import { _getSessionTabsEnvironmentKeyIndexCountsForTest } from './web-session-tabs-sync/session-tabs-environment-key-index' + +vi.mock('../store', () => ({ + useAppStore: { setState: vi.fn(), getState: vi.fn(() => ({})) } +})) +vi.mock('./web-session-terminal-handle-events', () => ({ + queueAcceptedWebSessionTerminalSnapshot: vi.fn() +})) + +const ENV = 'env-long-running' +const CYCLES = 10_000 + +function snapshotFor(worktree: string): RuntimeMobileSessionTabsResult { + return { + worktree, + publicationEpoch: `epoch-${worktree}`, + snapshotVersion: 1, + tabs: [], + groups: [], + tabBarOrder: [], + activeTabId: null + } as unknown as RuntimeMobileSessionTabsResult +} + +/** Each cycle is one worktree observed then removed by the host, as a long session does for days. */ +function driveWorktreeLifecycles(cycles: number): void { + for (let i = 0; i < cycles; i += 1) { + const worktree = `repo::/w/${i}` + recordReceivedWebSessionTabsSnapshot(ENV, snapshotFor(worktree), undefined, `runtime-${i}`) + clearWebSessionTabsTrackingForWorktree(ENV, worktree) + } +} + +describe('long-running session growth of session-tabs tracking maps', () => { + beforeEach(resetWebSessionTabsSnapshotFreshnessForTests) + + it('bounds the publication epoch tombstone history across 10k worktree lifecycles', () => { + driveWorktreeLifecycles(CYCLES) + + expect(sessionTabsPublicationEpochHistoryByWorktree.size).toBe( + MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY + ) + expect(MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY).toBeLessThanOrEqual(1024) + // Why: the tombstone is the only map a worktree removal deliberately retains. + expect(latestSessionTabsSnapshotByWorktree.size).toBe(0) + expect(replayableSessionTabsSnapshotByWorktree.size).toBe(0) + expect(latestReceivedSessionTabsSnapshotByWorktree.size).toBe(0) + expect(lastHostTerminalTabCountByWorktree.size).toBe(0) + expect(sessionTabsInventoryOmissionsByWorktree.size).toBe(0) + }) + + it('evicts the key index alongside the tombstone it was retained for', () => { + driveWorktreeLifecycles(CYCLES) + + expect(_getSessionTabsEnvironmentKeyIndexCountsForTest().worktrees).toBe( + MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY + ) + }) + + it('retains only the newest tombstones, so a just-removed worktree stays fenced', () => { + driveWorktreeLifecycles(CYCLES) + + expect(sessionTabsPublicationEpochHistoryByWorktree.has(`${ENV}:repo::/w/${CYCLES - 1}`)).toBe( + true + ) + expect(sessionTabsPublicationEpochHistoryByWorktree.has(`${ENV}:repo::/w/0`)).toBe(false) + }) + + it('keeps a still-publishing worktree fenced while removed neighbours churn past the cap', () => { + const live = 'repo::/w/live' + const liveFrame = (version: number): RuntimeMobileSessionTabsResult => ({ + ...snapshotFor(live), + publicationEpoch: 'epoch-live', + snapshotVersion: version + }) + // Fill to the cap with the live worktree noted first, so it is the oldest insert. + decideWebSessionTabsSnapshot(liveFrame(1), ENV) + for (let i = 0; i < MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY - 1; i += 1) { + const worktree = `repo::/w/churn-${i}` + recordReceivedWebSessionTabsSnapshot(ENV, snapshotFor(worktree)) + clearWebSessionTabsTrackingForWorktree(ENV, worktree) + } + expect(sessionTabsPublicationEpochHistoryByWorktree.size).toBe( + MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY + ) + + // Why: a live frame must move the entry off the eviction front, or the next + // removed worktree evicts a worktree that is still publishing. + decideWebSessionTabsSnapshot(liveFrame(2), ENV) + recordReceivedWebSessionTabsSnapshot(ENV, snapshotFor('repo::/w/churn-overflow')) + + expect(sessionTabsPublicationEpochHistoryByWorktree.has(`${ENV}:${live}`)).toBe(true) + expect(sessionTabsPublicationEpochHistoryByWorktree.has(`${ENV}:repo::/w/churn-0`)).toBe(false) + }) + + it('bounds the retired runtime-id history for one environment', () => { + driveWorktreeLifecycles(CYCLES) + + expect( + sessionTabsRuntimeHistoryByEnvironment.get(ENV)?.retired.length ?? 0 + ).toBeLessThanOrEqual(8) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts index fbab01b591b..b07c16255c9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts @@ -1,5 +1,7 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { releaseSessionTabsEnvironmentKeyedWorktree } from './session-tabs-environment-key-index' import { + MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY, latestReceivedSessionTabsFrameByEnvironment, sessionTabsPublicationEpochHistoryByWorktree, sessionTabsRuntimeHistoryByEnvironment, @@ -155,6 +157,8 @@ export function isHeadlessMergeSessionTabsPublication(publicationEpoch: string): } export function noteSessionTabsPublicationEpoch( + environmentId: string, + worktreeId: string, key: string, publicationEpoch: string ): SessionTabsPublicationEpochHistory { @@ -162,7 +166,29 @@ export function noteSessionTabsPublicationEpoch( sessionTabsPublicationEpochHistoryByWorktree.get(key), publicationEpoch, SESSION_TABS_RETIRED_EPOCH_LIMIT - ) + ) as SessionTabsPublicationEpochHistory + history.environmentId = environmentId + history.worktreeId = worktreeId + // Re-insert so map order is least-recently-noted first: every accepted frame + // renotes its epoch, so eviction reaches removed worktrees' tombstones first. + sessionTabsPublicationEpochHistoryByWorktree.delete(key) sessionTabsPublicationEpochHistoryByWorktree.set(key, history) + evictOldestSessionTabsPublicationEpochHistory() return history } + +function evictOldestSessionTabsPublicationEpochHistory(): void { + while ( + sessionTabsPublicationEpochHistoryByWorktree.size > MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY + ) { + const oldestKey = sessionTabsPublicationEpochHistoryByWorktree.keys().next().value + if (typeof oldestKey !== 'string') { + return + } + const oldest = sessionTabsPublicationEpochHistoryByWorktree.get(oldestKey) + sessionTabsPublicationEpochHistoryByWorktree.delete(oldestKey) + if (oldest?.environmentId !== undefined && oldest.worktreeId !== undefined) { + releaseSessionTabsEnvironmentKeyedWorktree(oldest.environmentId, oldest.worktreeId, oldestKey) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-environment-key-index.ts b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-environment-key-index.ts new file mode 100644 index 00000000000..95a4e4faa8c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-environment-key-index.ts @@ -0,0 +1,83 @@ +import { + lastHostTerminalTabCountByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsRemovalFenceByWorktree, + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + sessionTabsInventoryOmissionsByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRecoveryStateByWorktree +} from './state' + +/** + * Reverse index from an environment to the worktrees it has minted per-worktree + * keys for. Environment teardown runs once per environment on every pairing + * revision change, so scanning every key of every per-worktree map made that + * cost quadratic in the environment count. Mirrors the shape already used by + * `hostSessionTabMappingKeysByEnvironmentAndWorktree`. + */ +const keyedWorktreeIdsByEnvironment = new Map>() + +export function noteSessionTabsEnvironmentKeyedWorktree( + environmentId: string, + worktreeId: string +): void { + const worktreeIds = keyedWorktreeIdsByEnvironment.get(environmentId) + if (worktreeIds) { + worktreeIds.add(worktreeId) + return + } + keyedWorktreeIdsByEnvironment.set(environmentId, new Set([worktreeId])) +} + +/** Snapshot so callers may delete index entries while iterating. */ +export function getSessionTabsEnvironmentKeyedWorktrees(environmentId: string): string[] { + return [...(keyedWorktreeIdsByEnvironment.get(environmentId) ?? [])] +} + +/** Drops the index entry once the last per-worktree map has released the key. */ +export function releaseSessionTabsEnvironmentKeyedWorktree( + environmentId: string, + worktreeId: string, + key: string +): void { + if ( + latestSessionTabsSnapshotByWorktree.has(key) || + replayableSessionTabsSnapshotByWorktree.has(key) || + latestReceivedSessionTabsSnapshotByWorktree.has(key) || + sessionTabsPublicationEpochHistoryByWorktree.has(key) || + latestSessionTabsRemovalFenceByWorktree.has(key) || + sessionTabsRecoveryStateByWorktree.has(key) || + lastHostTerminalTabCountByWorktree.has(key) || + sessionTabsInventoryOmissionsByWorktree.has(key) + ) { + return + } + const worktreeIds = keyedWorktreeIdsByEnvironment.get(environmentId) + if (!worktreeIds) { + return + } + worktreeIds.delete(worktreeId) + if (worktreeIds.size === 0) { + keyedWorktreeIdsByEnvironment.delete(environmentId) + } +} + +export function dropSessionTabsEnvironmentKeyIndex(environmentId: string): void { + keyedWorktreeIdsByEnvironment.delete(environmentId) +} + +export function clearSessionTabsEnvironmentKeyIndex(): void { + keyedWorktreeIdsByEnvironment.clear() +} + +export function _getSessionTabsEnvironmentKeyIndexCountsForTest(): { + environments: number + worktrees: number +} { + let worktrees = 0 + for (const worktreeIds of keyedWorktreeIdsByEnvironment.values()) { + worktrees += worktreeIds.size + } + return { environments: keyedWorktreeIdsByEnvironment.size, worktrees } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts index 5b4a716fbb7..49bcbcd7b25 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts @@ -10,10 +10,7 @@ import { type TrackedWebSessionTabsWorktree } from './state' import { sessionTabsFreshnessKey } from './tracking' - -function omissionKey(environmentId: string, worktreeId: string): string { - return `${environmentId}:${worktreeId}` -} +import { noteSessionTabsEnvironmentKeyedWorktree } from './session-tabs-environment-key-index' function trackedWorktreeOmissionFingerprint( trackedWorktree: TrackedWebSessionTabsWorktree @@ -28,7 +25,7 @@ export function clearTrackedWebSessionTabsInventoryAbsence( environmentId: string, worktreeId: string ): void { - sessionTabsInventoryOmissionsByWorktree.delete(omissionKey(environmentId, worktreeId)) + sessionTabsInventoryOmissionsByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId)) } /** @@ -41,11 +38,12 @@ export function confirmTrackedWebSessionTabsInventoryAbsence( environmentId: string, trackedWorktree: TrackedWebSessionTabsWorktree ): boolean { - const key = omissionKey(environmentId, trackedWorktree.worktree) + const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree) const fingerprint = trackedWorktreeOmissionFingerprint(trackedWorktree) const cached = sessionTabsInventoryOmissionsByWorktree.get(key) const observations = cached?.fingerprint === fingerprint ? cached.observations + 1 : 1 sessionTabsInventoryOmissionsByWorktree.delete(key) + noteSessionTabsEnvironmentKeyedWorktree(environmentId, trackedWorktree.worktree) sessionTabsInventoryOmissionsByWorktree.set(key, { fingerprint, observations: Math.min(observations, 2) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index f44d4a3d18c..932407c3f60 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -26,7 +26,11 @@ export const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 export type SessionTabsStreamEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + | { + type: 'snapshots' + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean + } | { type: 'end' } export type SessionTabsListAllResult = { @@ -62,7 +66,13 @@ export type SessionTabsRuntimeHistory = RetiredValueHistory * of epochs that have already been superseded so those delayed frames cannot * roll the mirror back after the replacement epoch is accepted. */ -export type SessionTabsPublicationEpochHistory = RetiredValueHistory +export type SessionTabsPublicationEpochHistory = RetiredValueHistory & { + /** Set by `noteSessionTabsPublicationEpoch` so eviction can release the key index. */ + environmentId?: string + worktreeId?: string +} +/** Live worktrees refresh their entry on every accepted frame, so eviction reaches tombstones first. */ +export const MAX_SESSION_TABS_PUBLICATION_EPOCH_HISTORY = 512 export type SessionTabsRecoveryState = { pendingCount: number } export type SessionTabsRemovalFence = { receivedFrame: number @@ -142,8 +152,12 @@ export function resetReceivedSessionTabsFrameSequence(): void { } export type TerminalSurface = RuntimeMobileSessionTerminalClientTab -export type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } -export type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } +export type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { + status: 'ready' +} +export type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { + browserPageId: string +} export type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab export type MirroredAgentTab = { hostTabId: string; unifiedTab: Tab } @@ -164,7 +178,11 @@ export type MirroredBrowserTab = { hostTabId: string clientGroupId?: string } -export type MirroredEditorTab = { file: OpenFile; unifiedTab: Tab; hostTabId: string } +export type MirroredEditorTab = { + file: OpenFile + unifiedTab: Tab + hostTabId: string +} export type WebSessionTabsSyncState = Pick< AppState, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts index b5490650875..8b2d44603d5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -20,6 +20,7 @@ import { recordAcceptedWebSessionTabsEnvironment } from './tracking' import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' +import { noteSessionTabsEnvironmentKeyedWorktree } from './session-tabs-environment-key-index' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' @@ -71,7 +72,13 @@ export function decideWebSessionTabsSnapshot( // Inventory omissions use a client-only sentinel epoch; recording that // sentinel would retire the host epoch and reject the next live frame. if (snapshot.publicationEpoch !== VISIBILITY_INVENTORY_REMOVAL_EPOCH) { - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) + noteSessionTabsPublicationEpoch( + environmentId, + snapshot.worktree, + key, + snapshot.publicationEpoch + ) } clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) @@ -119,7 +126,8 @@ export function decideWebSessionTabsSnapshot( } rememberHostTerminalTabCount(environmentId, snapshot) replayableSessionTabsSnapshotByWorktree.delete(key) - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + noteSessionTabsPublicationEpoch(environmentId, snapshot.worktree, key, snapshot.publicationEpoch) + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) latestSessionTabsSnapshotByWorktree.set(key, { publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index 95470efe580..20094047649 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -44,6 +44,12 @@ import { import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration' import { clearHostMirrorHandleGapVerdictsForEnvironment } from '@/lib/host-mirror-handle-gap-wait' import { clearHostSessionTabIdMappings } from './tracking-mappings' +import { + clearSessionTabsEnvironmentKeyIndex, + dropSessionTabsEnvironmentKeyIndex, + getSessionTabsEnvironmentKeyedWorktrees, + noteSessionTabsEnvironmentKeyedWorktree +} from './session-tabs-environment-key-index' import { sessionTabsFreshnessKey, untrackWebSessionTabsWorktree, @@ -78,6 +84,7 @@ export function acceptReplayedWebSessionTabsSnapshot( const key = sessionTabsFreshnessKey(environmentId, worktreeId) const current = latestSessionTabsSnapshotByWorktree.get(key) if (current) { + noteSessionTabsEnvironmentKeyedWorktree(environmentId, worktreeId) replayableSessionTabsSnapshotByWorktree.set(key, current) } } @@ -99,6 +106,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { hostSessionTabIdByLocalKey.clear() hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() hostWorkingClientBoundaryByPaneKey.clear() + clearSessionTabsEnvironmentKeyIndex() resetWebSessionBrowserPlacementsForTests() } @@ -167,58 +175,29 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) if (!trimmedEnvironmentId) { return } - const keyPrefix = `${trimmedEnvironmentId}:` sessionTabsTrackingGenerationByEnvironment.set( trimmedEnvironmentId, (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 ) - for (const key of latestSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - replayableSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - } - } - sessionTabsRuntimeHistoryByEnvironment.delete(trimmedEnvironmentId) - for (const key of sessionTabsPublicationEpochHistoryByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsPublicationEpochHistoryByWorktree.delete(key) - } - } - latestReceivedSessionTabsFrameByEnvironment.delete(trimmedEnvironmentId) - latestReceivedSessionTabsInventoryFrameByEnvironment.delete(trimmedEnvironmentId) - for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - for (const key of sessionTabsRecoveryStateByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - } - trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) - for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) { + // Why: a prefix scan of every per-worktree map costs O(all environments x all + // worktrees) per teardown, and teardown runs once per environment. + for (const worktreeId of getSessionTabsEnvironmentKeyedWorktrees(trimmedEnvironmentId)) { + const key = sessionTabsFreshnessKey(trimmedEnvironmentId, worktreeId) + latestSessionTabsSnapshotByWorktree.delete(key) + replayableSessionTabsSnapshotByWorktree.delete(key) + latestReceivedSessionTabsSnapshotByWorktree.delete(key) + sessionTabsPublicationEpochHistoryByWorktree.delete(key) + latestSessionTabsRemovalFenceByWorktree.delete(key) + sessionTabsRecoveryStateByWorktree.delete(key) + lastHostTerminalTabCountByWorktree.delete(key) + sessionTabsInventoryOmissionsByWorktree.delete(key) removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId) } - for (const key of lastHostTerminalTabCountByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - lastHostTerminalTabCountByWorktree.delete(key) - } - } - for (const key of sessionTabsInventoryOmissionsByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsInventoryOmissionsByWorktree.delete(key) - } - } + dropSessionTabsEnvironmentKeyIndex(trimmedEnvironmentId) + sessionTabsRuntimeHistoryByEnvironment.delete(trimmedEnvironmentId) + latestReceivedSessionTabsFrameByEnvironment.delete(trimmedEnvironmentId) + latestReceivedSessionTabsInventoryFrameByEnvironment.delete(trimmedEnvironmentId) + trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) if (mappingKeysByWorktree) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index 1d6eea41055..96c9021f136 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -22,6 +22,7 @@ import { noteSessionTabsPublicationEpoch, recordReceivedWebSessionTabsEnvironmentFrame } from './publisher-identity-fences' +import { noteSessionTabsEnvironmentKeyedWorktree } from './session-tabs-environment-key-index' export function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( @@ -108,7 +109,8 @@ export function recordReceivedWebSessionTabsSnapshot( return frame } if (!history || history.current !== publicationEpoch) { - noteSessionTabsPublicationEpoch(key, publicationEpoch) + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) + noteSessionTabsPublicationEpoch(environmentId, snapshot.worktree, key, publicationEpoch) } // Stream delivery order is the freshest evidence even when a host's version // counter briefly moves backwards (for example across a visibility resume). @@ -121,6 +123,7 @@ export function recordReceivedWebSessionTabsSnapshot( snapshot.snapshotVersion > current.snapshotVersion || (snapshot.snapshotVersion === current.snapshotVersion && current.receivedFrame <= frame) ) { + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) latestReceivedSessionTabsSnapshotByWorktree.set(key, { receivedFrame: frame, publicationEpoch, @@ -147,8 +150,11 @@ export function beginWebSessionTabsSnapshotRecovery( receivedFrame: number ): () => void { const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } + const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { + pendingCount: 0 + } recoveryState.pendingCount += 1 + noteSessionTabsEnvironmentKeyedWorktree(environmentId, worktreeId) sessionTabsRecoveryStateByWorktree.set(key, recoveryState) let settled = false return () => { @@ -191,6 +197,7 @@ export function recordReceivedWebSessionTabsRemoval( latestSessionTabsRemovalFenceByWorktree.delete(key) return } + noteSessionTabsEnvironmentKeyedWorktree(environmentId, worktreeId) latestSessionTabsRemovalFenceByWorktree.set(key, { receivedFrame, recoveryState, @@ -244,6 +251,7 @@ export function recordAcceptedWebSessionTabsEnvironment( environments.delete(environmentId) } if (environments.size > 0) { + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments) } else { sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree) @@ -266,5 +274,6 @@ export function rememberHostTerminalTabCount( ): void { const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length + noteSessionTabsEnvironmentKeyedWorktree(environmentId, snapshot.worktree) lastHostTerminalTabCountByWorktree.set(key, terminalCount) }