480 lines
20 KiB
TypeScript
480 lines
20 KiB
TypeScript
/**
|
|
* 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)
|
|
|
|
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<void> {
|
|
// 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<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,
|
|
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<typeof createLargeFileCountRepo> | 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<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 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])
|
|
}
|
|
})
|
|
})
|