diff --git a/config/scripts/ssh-cleanup-tab-map-benchmark.mjs b/config/scripts/ssh-cleanup-tab-map-benchmark.mjs new file mode 100644 index 00000000000..8ddaa783dec --- /dev/null +++ b/config/scripts/ssh-cleanup-tab-map-benchmark.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe the baseline ssh-target-cleanup.ts source on stdin; no app or network is used. +const entry = path.resolve('src/renderer/src/store/slices/ssh-target-cleanup.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert( + sources.every((source) => source.includes('export function buildRemovedSshTargetCleanupPatch')) +) + +async function load(source, instrument = false) { + if (instrument) { + const spread = '...nextTabsByWorktree' + assert.equal(source.split(spread).length, 2) + source = source.replace(spread, '...countTabMapCopy(nextTabsByWorktree)') + source += ` + export const tabMapCopies = { count: 0, entries: 0 }; + function countTabMapCopy(map) { + tabMapCopies.count++; + tabMapCopies.entries += Reflect.ownKeys(map).length; + return map; + } + ` + } + source += "\nexport { toAppSshPtyId } from '../../../../shared/ssh-pty-id';" + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'cleanup-source', + setup(builder) { + builder.onLoad({ filter: /ssh-target-cleanup\.ts$/ }, () => ({ + contents: source, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=ssh-cleanup-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} + +const modules = await Promise.all(sources.map((source) => load(source))) +const arms = modules.map((module) => module.buildRemovedSshTargetCleanupPatch) +const { toAppSshPtyId } = modules[0] + +function emptyState() { + return { + repos: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryHistoryByTabId: {}, + deferredSshReconnectTargets: [], + transientClearedAgentStatusConnectionIds: {}, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + sshTargetGenerations: new Map(), + remoteWorkspaceHydratedTargetIds: new Set(), + remoteWorkspaceSyncStatusByTargetId: {}, + portForwardsByConnection: {}, + detectedPortsByConnection: {}, + sshCredentialQueue: [] + } +} + +function freezeState(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + if (value instanceof Map || value instanceof Set) { + for (const item of value.values()) { + freezeState(item) + } + } else { + for (const item of Object.values(value)) { + freezeState(item) + } + } + return Object.freeze(value) +} + +function tab(id, worktreeId, ptyId) { + return { + id, + worktreeId, + ptyId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + pendingActivationSpawn: true + } +} + +function timedState(count, stride, catalog, tabsPerWorkspace = 1) { + const state = emptyState() + if (catalog) { + state.repos.push({ id: 'repo', path: '/remote', connectionId: 'removed' }) + } + state.worktreesByRepo.repo = [] + for (let i = 0; i < count; i++) { + const selected = stride > 0 && i % stride === 0 + const worktreeId = catalog ? `repo::/remote/${i}` : `folder:${i}` + const ptyId = toAppSshPtyId(selected ? 'removed' : 'other', `pty-${i}`) + state.tabsByWorktree[worktreeId] = Array.from({ length: tabsPerWorkspace }, (_, j) => + tab(`tab-${i}-${j}`, worktreeId, ptyId) + ) + if (catalog) { + state.worktreesByRepo.repo.push({ id: worktreeId, repoId: 'repo', path: `/remote/${i}` }) + } + } + return freezeState(state) +} + +function compare(state, targetId) { + const before = structuredClone(state) + const results = arms.map((arm) => arm(state, targetId)) + assert.deepEqual(results[1], results[0]) + assert.deepEqual(state, before) + for (const key of Object.keys(results[0] ?? {})) { + assert.equal(results[1][key] === state[key], results[0][key] === state[key]) + } + for (const [key, tabs] of Object.entries(state.tabsByWorktree)) { + const next = results.map((result) => result?.tabsByWorktree?.[key] ?? tabs) + assert.equal(next[1] === tabs, next[0] === tabs) + for (let i = 0; i < tabs.length; i++) { + assert.equal(next[1][i] === tabs[i], next[0][i] === tabs[i]) + } + } + return results +} + +let seed = 0x15c0ffee +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} + +for (let iteration = 0; iteration < 3000; iteration++) { + const state = emptyState() + const targets = ['removed', 'other', 'space / @ Unicode 🐳'] + for (const targetId of targets) { + state.repos.push({ id: 'collision', path: `/remote/${targetId}`, connectionId: targetId }) + if (random(2)) { + state.sshConnectionStates.set(targetId, { targetId, status: 'disconnected' }) + } + if (random(2)) { + state.sshTargetLabels.set(targetId, targetId) + } + if (random(2)) { + state.sshTargetGenerations.set(targetId, random(10)) + } + if (random(2)) { + state.remoteWorkspaceHydratedTargetIds.add(targetId) + } + if (random(2)) { + state.deferredSshReconnectTargets.push(targetId) + } + if (random(2)) { + state.transientClearedAgentStatusConnectionIds[targetId] = true + } + if (random(2)) { + state.remoteWorkspaceSyncStatusByTargetId[targetId] = { phase: 'synced' } + } + if (random(2)) { + state.portForwardsByConnection[targetId] = [{ localPort: 8000 }] + } + if (random(2)) { + state.detectedPortsByConnection[targetId] = [{ port: 8001 }] + } + if (random(2)) { + state.sshCredentialQueue.push({ targetId, requestId: targetId, kind: 'password' }) + } + } + const count = random(24) + for (let i = 0; i < count; i++) { + const key = ['__proto__', 'constructor', 'toString'][i] ?? `folder:${i}` + const owner = targets[random(targets.length)] + const rows = Array.from({ length: random(5) }, (_, j) => { + const id = `tab-${i}-${j}` + const ptyId = [null, '', 'local-pty', 'ssh:bad', toAppSshPtyId(owner, `pty-${j}`)][random(5)] + if (random(3) === 0) { + state.ptyIdsByTabId[id] = [toAppSshPtyId(owner, 'split'), 'local-split'] + } + if (random(3) === 0) { + state.lastKnownRelayPtyIdByTabId[id] = toAppSshPtyId(owner, 'last') + } + if (random(2)) { + state.deferredSshSessionIdsByTabId[id] = ptyId ?? 'local' + } + if (random(2)) { + state.pendingReconnectPtyIdByTabId[id] = toAppSshPtyId(owner, 'reconnect') + } + if (ptyId) { + state.pendingCodexPaneRestartIds[ptyId] = true + state.codexRestartNoticeByPtyId[ptyId] = { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + } + } + const authority = { + targetId: targets[random(3)], + providerEpoch: 'epoch', + connectionGeneration: 1 + } + if (random(2)) { + state.directSshPaneRetryByTabId[id] = { authority, attemptId: id, tabGeneration: 1 } + } + if (random(2)) { + state.directSshLivePtyBindingByTabId[id] = { authority, ptyId, tabGeneration: 1 } + } + if (random(2)) { + state.directSshPaneRetryHistoryByTabId[id] = { authority, attemptedAt: [10] } + } + return tab(id, key, ptyId) + }) + Object.defineProperty(state.tabsByWorktree, key, { value: rows, enumerable: true }) + if (i >= 3 && random(2)) { + const worktree = { + id: key, + repoId: 'collision', + path: `/remote/${i}`, + hostId: `ssh:${encodeURIComponent(owner)}` + } + ;(state.worktreesByRepo.collision ??= []).push(worktree) + if (random(2)) { + state.detectedWorktreesByRepo.collision = { worktrees: [worktree] } + } + } + } + compare(freezeState(state), targets[random(3)]) +} +console.log('3,000 frozen-state full-patch / identity differential cases passed') + +// Instrument only the copy site for counts; timing arms above remain uninstrumented. +const counted = await Promise.all(sources.map((source) => load(source, true))) +for (const stride of [1, 10, 0]) { + const state = timedState(100, stride, false) + const expectedCopies = stride === 0 ? [0, 0] : [100 / stride, 1] + counted.forEach((module, index) => { + module.tabMapCopies.count = 0 + module.tabMapCopies.entries = 0 + assert.deepEqual( + module.buildRemovedSshTargetCleanupPatch(state, 'removed'), + arms[index](state, 'removed') + ) + assert.deepEqual(module.tabMapCopies, { + count: expectedCopies[index], + entries: expectedCopies[index] * 100 + }) + }) + console.log( + JSON.stringify({ stride, copiedEntries: counted.map((module) => module.tabMapCopies.entries) }) + ) +} + +function sample(arm, state, repeats) { + const start = performance.now() + let changed = 0 + for (let i = 0; i < repeats; i++) { + changed += arm(state, 'removed') !== null ? 1 : 0 + } + assert(changed === 0 || changed === repeats) + return (performance.now() - start) / repeats +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms', + pairs: 8 + }) +) +for (const [count, stride, catalog, tabsPerWorkspace] of [ + [1, 1, false, 1], + [10, 1, false, 1], + [100, 1, false, 1], + [500, 1, false, 1], + [1000, 1, false, 1], + [100, 10, false, 1], + [1000, 10, false, 1], + [1000, 0, false, 1], + [100, 1, true, 4], + [500, 1, true, 4] +]) { + const state = timedState(count, stride, catalog, tabsPerWorkspace) + compare(state, 'removed') + for (const arm of arms) { + const until = performance.now() + 80 + while (performance.now() < until) { + sample(arm, state, 1) + } + } + const repeats = Math.max(1, Math.min(20000, Math.ceil(40 / sample(arms[0], state, 1)))) + /** @type {number[][]} */ + const samples = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + samples[index].push(sample(arms[index], state, repeats)) + } + } + const median = samples.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log( + JSON.stringify({ count, stride, catalog, tabsPerWorkspace, repeats, median, samples }) + ) +} diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts new file mode 100644 index 00000000000..91872ff245e --- /dev/null +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { AppState } from '../types' +import { buildRemovedSshTargetCleanupPatch } from './ssh-target-cleanup' +import { createTestStore, makeTab } from './store-test-helpers' + +function freezeTabs(tabsByWorktree: AppState['tabsByWorktree']): AppState['tabsByWorktree'] { + for (const tabs of Object.values(tabsByWorktree)) { + tabs.forEach(Object.freeze) + Object.freeze(tabs) + } + return Object.freeze(tabsByWorktree) +} + +describe('SSH target cleanup tab map', () => { + it.each([1, 10])('preserves frozen inputs and untouched identities with stride %i', (stride) => { + const tabsByWorktree = freezeTabs( + Object.fromEntries( + Array.from({ length: 100 }, (_, index) => { + const worktreeId = `folder:${index}` + return [ + worktreeId, + [ + makeTab({ + id: `tab-${index}`, + worktreeId, + ptyId: toAppSshPtyId(index % stride === 0 ? 'removed' : 'other', 'pty'), + pendingActivationSpawn: true + }), + makeTab({ id: `untouched-${index}`, worktreeId, ptyId: 'local-pty' }) + ] + ] + }) + ) + ) + const state = Object.freeze({ ...createTestStore().getState(), tabsByWorktree }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree).not.toBe(tabsByWorktree) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(Object.keys(tabsByWorktree)) + Object.entries(tabsByWorktree).forEach(([key, tabs], index) => { + const nextTabs = patch.tabsByWorktree![key] + expect(nextTabs[1]).toBe(tabs[1]) + expect(tabs[0].pendingActivationSpawn).toBe(true) + expect(tabs[0].ptyId).not.toBeNull() + if (index % stride === 0) { + expect(nextTabs).not.toBe(tabs) + expect(nextTabs[0]).not.toBe(tabs[0]) + const { pendingActivationSpawn: _, ...retained } = tabs[0] + expect(nextTabs[0]).toEqual({ ...retained, ptyId: null }) + } else { + expect(nextTabs).toBe(tabs) + expect(nextTabs[0]).toBe(tabs[0]) + } + }) + }) + + it('clears folder tabs matched only by split or last-known PTYs', () => { + const removedPtyId = toAppSshPtyId('removed', 'pty') + const tabsByWorktree = freezeTabs({ + 'folder:split': [makeTab({ id: 'split', worktreeId: 'folder:split' })], + 'folder:last': [makeTab({ id: 'last', worktreeId: 'folder:last' })], + 'folder:empty': [makeTab({ id: 'empty', worktreeId: 'folder:empty' })] + }) + const state = Object.freeze({ + ...createTestStore().getState(), + tabsByWorktree, + ptyIdsByTabId: Object.freeze({ split: [removedPtyId] }), + lastKnownRelayPtyIdByTabId: Object.freeze({ last: removedPtyId }), + pendingCodexPaneRestartIds: Object.freeze({ [removedPtyId]: true as const }), + codexRestartNoticeByPtyId: Object.freeze({ + [removedPtyId]: { previousAccountLabel: 'old', nextAccountLabel: 'new' } + }) + }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree!['folder:split']).not.toBe(tabsByWorktree['folder:split']) + expect(patch.tabsByWorktree!['folder:last']).not.toBe(tabsByWorktree['folder:last']) + expect(patch.tabsByWorktree!['folder:empty']).toBe(tabsByWorktree['folder:empty']) + expect(patch.ptyIdsByTabId).toEqual({ split: [], last: [] }) + expect(patch.lastKnownRelayPtyIdByTabId).toEqual({}) + expect(patch.pendingCodexPaneRestartIds).toEqual({}) + expect(patch.codexRestartNoticeByPtyId).toEqual({}) + }) + + it.each([false, true])('preserves own special keys with null prototype = %s', (nullPrototype) => { + const keys = ['__proto__', 'constructor', 'toString', 'folder:normal'] + const tabsByWorktree = Object.fromEntries( + keys.map((worktreeId) => [ + worktreeId, + [makeTab({ id: `tab-${worktreeId}`, worktreeId, ptyId: toAppSshPtyId('removed', 'pty') })] + ]) + ) + if (nullPrototype) { + Object.setPrototypeOf(tabsByWorktree, null) + } + freezeTabs(tabsByWorktree) + const patch = buildRemovedSshTargetCleanupPatch( + Object.freeze({ ...createTestStore().getState(), tabsByWorktree }), + 'removed' + )! + expect(Object.getPrototypeOf(patch.tabsByWorktree)).toBe(Object.prototype) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(keys) + for (const key of keys) { + expect(Object.hasOwn(patch.tabsByWorktree!, key)).toBe(true) + expect(patch.tabsByWorktree![key][0].ptyId).toBeNull() + expect(tabsByWorktree[key][0].ptyId).not.toBeNull() + } + }) + + it('does not publish or replace the tab map when only target metadata changes', () => { + const store = createTestStore() + const tabsByWorktree = freezeTabs({ + 'folder:other': [ + makeTab({ + id: 'other', + worktreeId: 'folder:other', + ptyId: toAppSshPtyId('other', 'pty') + }) + ] + }) + store.setState({ tabsByWorktree }) + const before = store.getState() + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState()).toBe(before) + store.setState({ deferredSshReconnectTargets: ['removed'] }) + const patch = buildRemovedSshTargetCleanupPatch(store.getState(), 'removed') + expect(patch).toEqual({ deferredSshReconnectTargets: [] }) + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState().tabsByWorktree).toBe(tabsByWorktree) + }) +}) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 5750894936b..9cac633bedd 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -182,7 +182,10 @@ function clearSshTargetTabPtyState( } } if (nextTabs !== tabs) { - nextTabsByWorktree = { ...nextTabsByWorktree, [worktreeId]: nextTabs } + if (nextTabsByWorktree === state.tabsByWorktree) { + nextTabsByWorktree = { ...nextTabsByWorktree } + } + nextTabsByWorktree[worktreeId] = nextTabs } }