mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Fix untracked line-stat cache thrash and add source-control scale benchmark (#8013)
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 <help@stably.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<GitLineStats> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<LoadMeasurement> {
|
||||
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<number> {
|
||||
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<number> => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user