From a54f0bc336e4725d9ab14816ad4e0328639d1d7b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:05:47 -0700 Subject: [PATCH] Fix untracked line-stat cache thrash and add source-control scale benchmark (#8013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The untracked line-stat cache capped at 2,048 entries while a git status scan can carry up to DEFAULT_GIT_STATUS_LIMIT (10,000) untracked entries. A sequential scan over more files than the cap FIFO-evicted every entry before the next poll revisited it (~0% hit rate), so every 3s status poll re-read every untracked file's full contents. Measured with 64KB files: warm rescan cost per file was 17x higher just past the cap. - Size the cache to 2x the status entry limit and make eviction LRU (delete-before-set on hit and refresh) so a hot worktree's entries survive another worktree's scan. - Add tests/e2e/source-control-large-file-count.spec.ts: a 5-scenario Playwright benchmark (pnpm run test:e2e:source-control-scale) that reproduces #8013 deterministically — event-loop stall, DOM node count, JS heap, and OS-level renderer working set at 5k/9.5k/11k changed files, plus a clean-repo control and a cache-effectiveness gate. Scenarios asserting bounded row mounting go green with the SourceControl virtualization fix (#7619). Co-authored-by: Orca --- package.json | 1 + src/shared/git-uncommitted-line-stats.test.ts | 17 + src/shared/git-uncommitted-line-stats.ts | 17 +- tests/e2e/large-file-count-fixtures.ts | 133 ++++++ .../source-control-large-file-count.spec.ts | 431 ++++++++++++++++++ 5 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/large-file-count-fixtures.ts create mode 100644 tests/e2e/source-control-large-file-count.spec.ts diff --git a/package.json b/package.json index a24b1d1d669..de31411d156 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index fc98a3d8210..d9e876113aa 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -135,6 +135,23 @@ describe('collectUntrackedAdditions', () => { expect(stats.get('cached.ts')).toEqual({ added: 3 }) expect(readFileMock).toHaveBeenCalledTimes(1) }) + + it('keeps the cache effective across polls for a status-limit-sized change set', async () => { + // Why: git status caps at DEFAULT_GIT_STATUS_LIMIT (10,000) entries. A + // cache smaller than one scan FIFO-evicts every entry mid-scan, so the + // next poll re-reads every file (#8013). Scan the full limit twice; the + // second pass must be stat-only. + lstatMock.mockResolvedValue(mockFileStat(5, 7)) + readFileMock.mockResolvedValue(Buffer.from('a\nb\nc')) + const paths = Array.from({ length: 10_000 }, (_, i) => `poll-scale/file-${i}.ts`) + + await collectUntrackedAdditions('/repo', paths) + const firstPassReads = readFileMock.mock.calls.length + await collectUntrackedAdditions('/repo', paths) + + expect(firstPassReads).toBe(paths.length) + expect(readFileMock).toHaveBeenCalledTimes(paths.length) + }) }) describe('applyLineStats', () => { diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 44f45671d4d..3fe4c5cc99d 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -2,6 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import * as path from 'node:path' import { isBinaryBuffer } from './binary-buffer' import { decodeGitCQuotedPath } from './git-cquoted-path' +import { DEFAULT_GIT_STATUS_LIMIT } from './git-status-limit' export type GitLineStats = { added?: number; removed?: number } @@ -11,7 +12,13 @@ const UNTRACKED_READ_CONCURRENCY = 8 // Keep status polling cheap: large untracked files are commonly generated // assets, and reading them every poll can stall the source-control sidebar. export const MAX_UNTRACKED_LINE_COUNT_BYTES = 2 * 1024 * 1024 -const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2048 +// Why: the cache must hold at least one full status scan's untracked set +// (capped at DEFAULT_GIT_STATUS_LIMIT entries). A smaller cache is worse than +// none: a sequential scan over more files than the cap evicts every entry +// before the next poll revisits it, so every poll re-reads every untracked +// file's contents (#8013). 2x leaves headroom for a second window polling a +// different worktree; entries are ~200 bytes, so worst case is a few MB. +const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2 * DEFAULT_GIT_STATUS_LIMIT const NEWLINE_BYTE = 0x0a type CachedUntrackedStats = { @@ -107,6 +114,11 @@ async function countFileAdditions(absolutePath: string): Promise { cached.mtimeMs === fileStat.mtimeMs && cached.ctimeMs === fileStat.ctimeMs ) { + // Why: Map eviction below removes the oldest-inserted key; re-inserting + // on hit makes that LRU instead of FIFO, so a hot worktree's entries + // survive another worktree's scan sharing this cache. + untrackedStatsCache.delete(absolutePath) + untrackedStatsCache.set(absolutePath, cached) return cached.stats } if (fileStat.isSymbolicLink()) { @@ -144,6 +156,9 @@ function rememberUntrackedStats( fileStat: { size: number; mtimeMs: number; ctimeMs: number }, stats: GitLineStats ): GitLineStats { + // Why: delete-before-set keeps refreshed entries at the recent end of the + // Map's insertion order, preserving the LRU eviction contract. + untrackedStatsCache.delete(absolutePath) untrackedStatsCache.set(absolutePath, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, diff --git a/tests/e2e/large-file-count-fixtures.ts b/tests/e2e/large-file-count-fixtures.ts new file mode 100644 index 00000000000..f2f8c58076c --- /dev/null +++ b/tests/e2e/large-file-count-fixtures.ts @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export type LargeFileCountRepoOptions = { + /** Files committed on the initial commit. */ + trackedFiles?: number + /** Files written after the commit and left untracked. */ + untrackedFiles?: number + /** Tracked files rewritten after the commit (unstaged modifications). */ + modifiedFiles?: number + /** Fan-out per directory; issue #8013 repos are wide trees, not one flat dir. */ + filesPerDirectory?: number + /** + * Minimum size of each untracked file. Untracked line stats read full file + * contents (mtime-cached, but the cache caps at 2,048 entries), so per-poll + * I/O cost only shows up when untracked files have realistic sizes. + */ + untrackedFileBytes?: number +} + +export type LargeFileCountRepo = { + repoPath: string + trackedFiles: number + untrackedFiles: number + modifiedFiles: number +} + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +/** + * Removes a fixture repo, retrying ENOTEMPTY/EBUSY races: the app's git status + * poll can still be writing index.lock (or the watcher holding dirs) while the + * test tears down. + */ +export async function removeLargeFileCountRepo(repoPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + rmSync(repoPath, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === 4) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) + } + } +} + +function writeFileTree( + repoPath: string, + rootDirName: string, + fileCount: number, + filesPerDirectory: number, + contentForIndex: (index: number) => string +): void { + let currentDir: string | null = null + for (let index = 0; index < fileCount; index += 1) { + const dirIndex = Math.floor(index / filesPerDirectory) + const dirPath = path.join(repoPath, rootDirName, `dir-${String(dirIndex).padStart(4, '0')}`) + if (dirPath !== currentDir) { + mkdirSync(dirPath, { recursive: true }) + currentDir = dirPath + } + writeFileSync( + path.join(dirPath, `file-${String(index).padStart(6, '0')}.ts`), + contentForIndex(index) + ) + } +} + +/** + * Repro fixture for issue #8013: a repo whose file count (tracked, untracked, + * or modified) is large enough to stress every per-file code path — the + * streamed `git status` scan, per-entry line stats, the IPC payload, and the + * Source Control row rendering. + */ +export function createLargeFileCountRepo( + options: LargeFileCountRepoOptions = {} +): LargeFileCountRepo { + const trackedFiles = options.trackedFiles ?? 0 + const untrackedFiles = options.untrackedFiles ?? 0 + const modifiedFiles = Math.min(options.modifiedFiles ?? 0, trackedFiles) + const filesPerDirectory = options.filesPerDirectory ?? 100 + + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-large-file-count-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + runGit(repoPath, ['config', 'core.autocrlf', 'false']) + + writeFileSync(path.join(repoPath, 'README.md'), '# Large file count fixture\n') + writeFileTree( + repoPath, + 'src', + trackedFiles, + filesPerDirectory, + (index) => `export const tracked${index} = ${index}\n` + ) + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial large file count fixture', '--quiet']) + + const untrackedFileBytes = options.untrackedFileBytes ?? 0 + const untrackedPadding = + untrackedFileBytes > 0 + ? `// ${'x'.repeat(78)}\n`.repeat(Math.ceil(untrackedFileBytes / 81)) + : '' + writeFileTree( + repoPath, + 'generated', + untrackedFiles, + filesPerDirectory, + (index) => `export const untracked${index} = ${index}\n${untrackedPadding}` + ) + + for (let index = 0; index < modifiedFiles; index += 1) { + const dirIndex = Math.floor(index / filesPerDirectory) + writeFileSync( + path.join( + repoPath, + 'src', + `dir-${String(dirIndex).padStart(4, '0')}`, + `file-${String(index).padStart(6, '0')}.ts` + ), + `export const tracked${index} = ${index}\nexport const modified${index} = true\n` + ) + } + + return { repoPath, trackedFiles, untrackedFiles, modifiedFiles } +} diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts new file mode 100644 index 00000000000..2518838f586 --- /dev/null +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -0,0 +1,431 @@ +/** + * 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) + + return 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 } + ) +} + +/** + * 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 + }) => { + 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 + }) + 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 removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('thousands of modified tracked files under the status cap stay responsive', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const modifiedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '5000') + const fixture = createLargeFileCountRepo({ trackedFiles: modifiedFiles, modifiedFiles }) + 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 removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('a change set over the status cap degrades to the too-many-changes state', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const untrackedFiles = DEFAULT_GIT_STATUS_LIMIT + 1_000 + const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles }) + 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 removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('untracked line-stat cache stays effective above 2,048 files', async ({ orcaPage }) => { + test.setTimeout(600_000) + // Why: the untracked line-stat cache caps at 2,048 entries with FIFO + // eviction. A sequential scan over more files than the cap evicts every + // entry before the next poll revisits it (0% hit rate), so each 3s poll + // re-reads every untracked file's contents. Compare per-file warm rescan + // cost under vs over the cap 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 + }) + const largeRepo = createLargeFileCountRepo({ + trackedFiles: 10, + untrackedFiles: 4_000, + untrackedFileBytes: fileBytes + }) + try { + 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 removeLargeFileCountRepo(smallRepo.repoPath) + await removeLargeFileCountRepo(largeRepo.repoPath) + } + }) + + test('a large clean repo (tracked files only) loads instantly', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const trackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '15000') + const fixture = createLargeFileCountRepo({ trackedFiles }) + 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 removeLargeFileCountRepo(fixture.repoPath) + } + }) +})