/** * Benchmark + repro suite for issue #8013: repos with ~10,000+ files make the * renderer's memory grow and the UI unresponsive. * * Each scenario builds an isolated on-disk repo at a controlled scale, drives * the real status pipeline (git scan → line stats → IPC → store → Source * Control rows), and measures: * - scanMs main-process `git status` + line-stat duration * - payloadBytes serialized status payload size crossing IPC * - renderMs time from setGitStatus until rows are in the DOM * - maxLagMs/p95 event-loop lag while loading (UI responsiveness) * - domNodeCount total DOM nodes after render * - heap growth JS heap across repeated poll cycles (leak signal) * * Run a single scenario at a custom scale: * ORCA_LARGE_FILE_COUNT=9500 npx playwright test \ * tests/e2e/source-control-large-file-count.spec.ts \ * --config tests/playwright.config.ts --project electron-headless */ import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import { createLargeFileCountRepo, removeLargeFileCountRepo } from './large-file-count-fixtures' import { DEFAULT_GIT_STATUS_LIMIT } from '../../src/shared/git-status-limit' // Matches the large-diff freeze budget: a blocking stall past 1s is the // "UI becomes unresponsive" symptom reported in #8013. const MAX_EVENT_LOOP_LAG_MS = 1_000 // A second full status→store→render cycle with identical data must not leak // unbounded memory; allow generous headroom for GC timing noise. const MAX_HEAP_GROWTH_PER_CYCLE_MB = 75 // A virtualized list mounts viewport + overscan rows only; anything past this // bound means the panel is mounting rows proportional to the change set again. const MAX_MOUNTED_ROWS = 1_000 type LoadMeasurement = { entryCount: number didHitLimit: boolean scanMs: number rescanMs: number payloadBytes: number renderMs: number renderedRows: number maxLagMs: number p95LagMs: number domNodeCount: number heapUsedMbAfterRender: number heapUsedMbPerCycle: number[] cycleMaxLagMs: number[] } async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise { const repoId = await orcaPage.evaluate(async (pathToRepo: string) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } const addedRepo = await store.getState().addRepoPath(pathToRepo) if (!addedRepo) { throw new Error(`isolated repo not found: ${pathToRepo}`) } return addedRepo.id }, repoPath) // Why: fetchWorktrees() resolves before Zustand always reflects the async // worktree scan, so poll the same public store path real repo setup uses. await expect .poll( () => orcaPage.evaluate(async (targetRepoId: string) => { const store = window.__store if (!store) { return 0 } await store.getState().fetchWorktrees(targetRepoId) return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0 }, repoId), { timeout: 30_000, message: 'isolated large-file-count worktree did not load' } ) .toBeGreaterThan(0) const worktreeId = await orcaPage.evaluate( ({ targetRepoId, pathToRepo }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } const state = store.getState() const worktrees = state.worktreesByRepo[targetRepoId] ?? [] const worktree = worktrees.find((entry) => entry.path === pathToRepo) ?? worktrees[0] if (!worktree) { throw new Error(`isolated worktree not found: ${pathToRepo}`) } state.setActiveRepo(targetRepoId) state.setActiveWorktree(worktree.id) state.setRightSidebarOpen(true) state.setRightSidebarTab('source-control') return worktree.id }, { targetRepoId: repoId, pathToRepo: repoPath } ) // Why: repo activation can finish sidebar routing after the store mutation; // assert the user-visible panel before timing its render. Clicking the // already-active activity button races the first cold status scan and tests // Playwright's two-frame actionability window instead of panel readiness. const sourceControlButton = orcaPage.getByRole('button', { name: /^Source Control/ }) await expect(sourceControlButton).toBeVisible() await expect .poll(() => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab)) .toBe('source-control') await expect(orcaPage.getByRole('button', { name: 'Filter files by name' })).toBeVisible() return worktreeId } async function unregisterLargeFileCountRepos( orcaPage: Page, repoPaths: readonly string[] ): Promise { // Why: remove disposable projects through the product so their terminals // and watcher subscriptions begin shutting down before Electron teardown. for (const repoPath of repoPaths) { await orcaPage.evaluate(async (pathToRepo) => { const store = window.__store const repo = store?.getState().repos.find((entry) => entry.path === pathToRepo) if (repo) { await store?.getState().removeProject(repo.id) } }, repoPath) } } /** * Drives the exact pipeline the panel's poll uses (api.git.status → * setGitStatus) so results are deterministic even while the built-in 3s poll * runs concurrently — the poll produces identical entries, which the store * dedupes. */ async function measureSourceControlLoad( orcaPage: Page, args: { worktreeId: string; repoPath: string; expectedRows: number; pollCycles: number } ): Promise { return await orcaPage.evaluate(async ({ worktreeId, repoPath, expectedRows, pollCycles }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } const lagSamples: number[] = [] const probeIntervalMs = 50 let lastTick = performance.now() const probe = window.setInterval(() => { const now = performance.now() lagSamples.push(Math.max(0, now - lastTick - probeIntervalMs)) lastTick = now }, probeIntervalMs) const readHeapMb = (): number => { const memory = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory return memory ? memory.usedJSHeapSize / (1024 * 1024) : -1 } try { const scanStart = performance.now() const status = await window.api.git.status({ worktreePath: repoPath }) const scanMs = performance.now() - scanStart const payloadBytes = JSON.stringify(status).length // Why: a virtualized panel mounts only viewport rows, so "all rows in // the DOM" would never happen post-fix. First rows appearing is the // user-visible "list loaded" moment either way. const firstRowsTarget = Math.min(expectedRows, 30) const renderStart = performance.now() store.getState().setGitStatus(worktreeId, status) let renderedRows = 0 while (performance.now() - renderStart < 60_000) { renderedRows = document.querySelectorAll('[data-testid="source-control-entry"]').length if (renderedRows >= firstRowsTarget) { break } await new Promise((resolve) => window.setTimeout(resolve, 50)) } const renderMs = performance.now() - renderStart // Settle so the lag probe captures post-render layout/paint stalls too. await new Promise((resolve) => window.setTimeout(resolve, 1_000)) renderedRows = document.querySelectorAll('[data-testid="source-control-entry"]').length const heapUsedMbAfterRender = readHeapMb() // Why: #8013 reports memory that keeps growing — replay the poll cycle // (fresh scan, fresh entry array, store update) and track heap + lag. const heapUsedMbPerCycle: number[] = [] const cycleMaxLagMs: number[] = [] let rescanMs = 0 for (let cycle = 0; cycle < pollCycles; cycle += 1) { const cycleLagStart = lagSamples.length const cycleScanStart = performance.now() const cycleStatus = await window.api.git.status({ worktreePath: repoPath }) if (cycle === 0) { // First rescan isolates warm-cache scan cost (untracked line-stat // reads are mtime-cached after the initial scan). rescanMs = performance.now() - cycleScanStart } store.getState().setGitStatus(worktreeId, cycleStatus) await new Promise((resolve) => window.setTimeout(resolve, 500)) heapUsedMbPerCycle.push(readHeapMb()) cycleMaxLagMs.push(Math.max(0, ...lagSamples.slice(cycleLagStart))) } const sorted = [...lagSamples].sort((a, b) => a - b) return { entryCount: status.entries.length, didHitLimit: status.didHitLimit === true, scanMs, rescanMs, payloadBytes, renderMs, renderedRows, maxLagMs: sorted.length ? (sorted.at(-1) ?? 0) : 0, p95LagMs: sorted.length ? sorted[Math.floor(sorted.length * 0.95)] : 0, domNodeCount: document.getElementsByTagName('*').length, heapUsedMbAfterRender, heapUsedMbPerCycle, cycleMaxLagMs } } finally { window.clearInterval(probe) } }, args) } function logMeasurement( label: string, measurement: LoadMeasurement & { rendererWorkingSetMb?: { before: number; after: number } } ): void { console.log(`[large-file-count] ${label} ${JSON.stringify(measurement)}`) } /** * OS-level working set of all renderer processes, in MB. The #8013 report is * about renderer memory; JS heap alone misses DOM/native memory, which * dominates when hundreds of thousands of nodes mount. */ async function readRendererWorkingSetMb(electronApp: ElectronApplication): Promise { return await electronApp.evaluate(({ app }) => { let totalKb = 0 for (const metric of app.getAppMetrics()) { if (metric.type === 'Tab') { totalKb += metric.memory.workingSetSize } } return totalKb / 1024 }) } test.describe('Source Control large file count (#8013)', () => { // Why: each scenario gets its own Electron app + isolated fixture repo, so a // failing scale must not skip the others — every scenario is a data point. test.use({ seedTestRepo: false }) test('thousands of untracked files under the status cap stay responsive', async ({ orcaPage, electronApp, registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '9500') // Why: ORCA_LARGE_FILE_BYTES gives untracked files realistic sizes so the // per-poll line-stat reads (cache-capped at 2,048 entries) become visible // in rescanMs instead of hiding behind ~30-byte fixture files. const untrackedFileBytes = Number(process.env.ORCA_LARGE_FILE_BYTES ?? '0') const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles, untrackedFileBytes }) registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) const measurement = await measureSourceControlLoad(orcaPage, { worktreeId, repoPath: fixture.repoPath, expectedRows: untrackedFiles, pollCycles: 3 }) const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) logMeasurement(`untracked=${untrackedFiles}`, { ...measurement, rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } }) expect(measurement.didHitLimit).toBe(false) expect(measurement.entryCount).toBeGreaterThanOrEqual(untrackedFiles) expect(measurement.renderedRows).toBeGreaterThan(0) // Why: mounting rows proportional to the change set is the #8013 bug; // a virtualized panel mounts viewport + overscan only. expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) if (measurement.heapUsedMbPerCycle.length > 1) { const first = measurement.heapUsedMbPerCycle[0] const last = measurement.heapUsedMbPerCycle.at(-1) ?? first expect(last - first).toBeLessThan( MAX_HEAP_GROWTH_PER_CYCLE_MB * (measurement.heapUsedMbPerCycle.length - 1) ) } } finally { await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('thousands of modified tracked files under the status cap stay responsive', async ({ orcaPage, electronApp, registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const modifiedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '5000') const fixture = createLargeFileCountRepo({ trackedFiles: modifiedFiles, modifiedFiles }) registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) const measurement = await measureSourceControlLoad(orcaPage, { worktreeId, repoPath: fixture.repoPath, expectedRows: modifiedFiles, pollCycles: 3 }) const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) logMeasurement(`modified=${modifiedFiles}`, { ...measurement, rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } }) expect(measurement.didHitLimit).toBe(false) expect(measurement.entryCount).toBeGreaterThanOrEqual(modifiedFiles) expect(measurement.renderedRows).toBeGreaterThan(0) expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('a change set over the status cap degrades to the too-many-changes state', async ({ orcaPage, electronApp, registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = DEFAULT_GIT_STATUS_LIMIT + 1_000 const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles }) registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) const measurement = await measureSourceControlLoad(orcaPage, { worktreeId, repoPath: fixture.repoPath, // The capped payload still carries DEFAULT_GIT_STATUS_LIMIT entries; // the panel may render them, but must stay responsive while doing so. expectedRows: 0, pollCycles: 1 }) const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) logMeasurement(`over-cap untracked=${untrackedFiles}`, { ...measurement, rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } }) expect(measurement.didHitLimit).toBe(true) expect(measurement.entryCount).toBeLessThanOrEqual(DEFAULT_GIT_STATUS_LIMIT) expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) // Why: didHitLimit must park the worktree in the huge-status state so // background polling stops re-running tens-of-seconds git scans. const hugeState = await orcaPage.evaluate( (wId) => window.__store?.getState().gitStatusHugeByWorktree?.[wId] ?? null, worktreeId ) expect(hugeState).not.toBeNull() } finally { await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('untracked line-stat cache stays effective above 2,048 files', async ({ orcaPage, registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) // Why: the untracked line-stat cache historically capped at 2,048 entries // with FIFO eviction, so a sequential scan over more files evicted every // entry before the next poll revisited it (0% hit rate) and each 3s poll // re-read every untracked file's contents. The cache is now LRU and sized // to the status entry limit; this gate keeps it that way by comparing // per-file warm rescan cost at two scales on the same machine — a healthy // cache keeps the ratio near 1, thrash makes it several-fold. const fileBytes = 65_536 const smallRepo = createLargeFileCountRepo({ trackedFiles: 10, untrackedFiles: 2_000, untrackedFileBytes: fileBytes }) registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(smallRepo.repoPath)) let largeRepo: ReturnType | null = null try { largeRepo = createLargeFileCountRepo({ trackedFiles: 10, untrackedFiles: 4_000, untrackedFileBytes: fileBytes }) const largeRepoPath = largeRepo.repoPath registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(largeRepoPath)) await waitForSessionReady(orcaPage) const warmRescanPerFileMs = async (repoPath: string, files: number): Promise => { const worktreeId = await addAndActivateRepo(orcaPage, repoPath) const measurement = await measureSourceControlLoad(orcaPage, { worktreeId, repoPath, expectedRows: files, pollCycles: 1 }) return measurement.rescanMs / files } const smallPerFileMs = await warmRescanPerFileMs(smallRepo.repoPath, 2_000) const largePerFileMs = await warmRescanPerFileMs(largeRepo.repoPath, 4_000) console.log( `[large-file-count] line-stat-cache smallPerFileMs=${smallPerFileMs.toFixed(4)} largePerFileMs=${largePerFileMs.toFixed(4)} ratio=${(largePerFileMs / smallPerFileMs).toFixed(2)}` ) expect(largePerFileMs).toBeLessThan(smallPerFileMs * 2) } finally { await unregisterLargeFileCountRepos(orcaPage, [ smallRepo.repoPath, ...(largeRepo ? [largeRepo.repoPath] : []) ]) } }) test('a large clean repo (tracked files only) loads instantly', async ({ orcaPage, electronApp, registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const trackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '15000') const fixture = createLargeFileCountRepo({ trackedFiles }) registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) const measurement = await measureSourceControlLoad(orcaPage, { worktreeId, repoPath: fixture.repoPath, expectedRows: 0, pollCycles: 2 }) const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) logMeasurement(`clean tracked=${trackedFiles}`, { ...measurement, rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } }) expect(measurement.entryCount).toBe(0) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) })