Files
orca/src/main/workspace-space-analysis-du-timeout.test.ts
T
Jinjing 9a8e21a47e fix(workspace-space): bound traversal memory and serialize local disk scans (#11026)
* fix(workspace-space): serialize local disk and cap traversal memory

Prevent resource exhaustion during large workspace scans by limiting
local disk access to one concurrent `du` call and capping portable
traversal memory to 100k entries or 64 MiB per worktree. Fixes July 27
incident with 298 worktrees causing host stalls and renderer OOM.

Portable traversals now use fixed-worker iterative frames instead of
recursive promises. Capacity failures become unavailable rows. Behavior
below limits is unchanged.

* fix(workspace-space): bound concurrent SSH fallback traversals

Desktop-side SSH fallback traversals run in the main process with independent admission
budgets. Without limiting, up to six concurrent traversals could stack six 64 MiB budgets.
Cap remote fallback traversals to 2 concurrent, keeping aggregate admission at 2 × 64 MiB.

Also make capacity error messages reflect configured limits instead of hardcoded defaults.
2026-07-27 18:50:52 -07:00

168 lines
4.6 KiB
TypeScript

import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeProcess from 'node:process'
import type { Repo } from '../shared/types'
import type { Store } from './persistence'
const { execFileMock, listRepoWorktreesMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
listRepoWorktreesMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
vi.mock('node:process', async () => {
const actual = await vi.importActual<typeof NodeProcess>('node:process')
return { ...actual, platform: 'darwin' }
})
vi.mock('./repo-worktrees', () => ({
createFolderWorktree: (repo: Repo) => ({
path: repo.path,
head: '',
branch: '',
isBare: false,
isMainWorktree: true
}),
listRepoWorktrees: listRepoWorktreesMock
}))
vi.mock('./providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
vi.mock('./providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn()
}))
import { analyzeWorkspaceSpace } from './workspace-space-analysis'
function createStore(repos: Repo[]): Store {
return {
getRepos: () => repos,
getWorktreeMeta: () => undefined
} as unknown as Store
}
describe('analyzeWorkspaceSpace local du timeout', () => {
let tempDir: string | null = null
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-space-du-timeout-'))
execFileMock.mockReset()
listRepoWorktreesMock.mockReset()
})
afterEach(async () => {
vi.useRealTimers()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
it('falls back when du never reports completion', async () => {
const repoPath = join(tempDir!, 'repo')
await mkdir(repoPath, { recursive: true })
await writeFile(join(repoPath, 'app.ts'), 'console.log("ok")\n')
const repo: Repo = {
id: 'repo-1',
path: repoPath,
displayName: 'orca',
badgeColor: '#000',
addedAt: 0
}
listRepoWorktreesMock.mockResolvedValue([
{
path: repoPath,
head: 'a',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
const killMock = vi.fn()
execFileMock.mockImplementation(() => ({ kill: killMock }))
vi.useFakeTimers()
let settled = false
const scanPromise = analyzeWorkspaceSpace(createStore([repo])).then((scan) => {
settled = true
return scan
})
await vi.waitFor(() =>
expect(execFileMock).toHaveBeenCalledWith(
'du',
['-k', '-d', '1', repoPath],
expect.any(Object),
expect.any(Function)
)
)
await vi.advanceTimersByTimeAsync(120_000)
await vi.waitFor(() => expect(settled).toBe(true))
await expect(scanPromise).resolves.toMatchObject({
scannedWorktreeCount: 1,
unavailableWorktreeCount: 0,
worktrees: [expect.objectContaining({ status: 'ok', path: repoPath })]
})
expect(killMock).toHaveBeenCalled()
})
it('runs one local du traversal at a time across repos', async () => {
const repoPaths = [join(tempDir!, 'repo-one'), join(tempDir!, 'repo-two')]
await Promise.all(repoPaths.map((repoPath) => mkdir(repoPath, { recursive: true })))
const repos: Repo[] = repoPaths.map((repoPath, index) => ({
id: `repo-${index}`,
path: repoPath,
displayName: `repo-${index}`,
badgeColor: '#000',
addedAt: 0
}))
listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [
{
path: repo.path,
head: 'a',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
const completions: (() => void)[] = []
let active = 0
let peak = 0
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout: string) => void
) => {
const rootPath = args.at(-1)!
active += 1
peak = Math.max(peak, active)
completions.push(() => {
active -= 1
callback(null, `1\t${rootPath}\n`)
})
return { kill: vi.fn() }
}
)
const scan = analyzeWorkspaceSpace(createStore(repos))
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(1))
completions.shift()?.()
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2))
completions.shift()?.()
await expect(scan).resolves.toMatchObject({ scannedWorktreeCount: 2 })
expect(peak).toBe(1)
})
})