Files
orca/src/shared/workspace-space-entry-traversal.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

292 lines
8.2 KiB
TypeScript

import {
createEntryScan,
type DirectoryFrame,
type EntryJob,
type WorkspaceSpaceEntryIdentity,
type WorkspaceSpaceEntryScan
} from './workspace-space-directory-frame'
import {
collectWorkspaceSpaceDirectoryEntries,
createWorkspaceSpaceScanBudget,
releaseWorkspaceSpaceScanEntries,
WorkspaceSpaceScanCapacityError,
type WorkspaceSpaceDirectoryAdmission,
type WorkspaceSpaceScanBudget,
type WorkspaceSpaceScanLimits
} from './workspace-space-scan-budget'
export type { WorkspaceSpaceEntryScan } from './workspace-space-directory-frame'
type WorkspaceSpaceEntryTraversalOptions<TEntry> = {
rootPath: string
rootName: string
concurrency: number
signal?: AbortSignal
entryName: (entry: TEntry) => string
joinPath: (parent: string, child: string) => string
classifyEntry: (path: string, sourceEntry: TEntry | null) => Promise<WorkspaceSpaceEntryIdentity>
readDirectory: (path: string) => Promise<AsyncIterable<TEntry> | Iterable<TEntry>>
checkCancelled: () => void
createCancellationError: () => Error
isCancellationError: (error: unknown) => boolean
limits?: Partial<WorkspaceSpaceScanLimits>
}
async function readDirectoryOrNull<TEntry>(
path: string,
options: WorkspaceSpaceEntryTraversalOptions<TEntry>,
budget: WorkspaceSpaceScanBudget
): Promise<WorkspaceSpaceDirectoryAdmission<TEntry> | null> {
try {
const directory = await options.readDirectory(path)
const admission = await collectWorkspaceSpaceDirectoryEntries(
directory,
path,
options.entryName,
budget,
options.checkCancelled
)
options.checkCancelled()
return admission
} catch (error) {
if (options.isCancellationError(error) || error instanceof WorkspaceSpaceScanCapacityError) {
throw error
}
return null
}
}
/**
* Scans one directory tree with a fixed worker pool. Directory frames retain
* the source arrays returned by readdir, but never allocate one promise or
* queued closure per entry; only the configured workers own live entry jobs.
*/
export async function scanWorkspaceSpaceEntryTree<TEntry>(
options: WorkspaceSpaceEntryTraversalOptions<TEntry>
): Promise<WorkspaceSpaceEntryScan> {
const budget = createWorkspaceSpaceScanBudget(options.limits)
options.checkCancelled()
const rootIdentity = await options.classifyEntry(options.rootPath, null)
options.checkCancelled()
const root = createEntryScan(options.rootPath, options.rootName, rootIdentity)
if (root.kind !== 'directory') {
return root
}
const rootAdmission = await readDirectoryOrNull(options.rootPath, options, budget)
if (rootAdmission === null) {
root.skippedEntryCount = 1
return root
}
const rootEntries = rootAdmission.entries
if (rootEntries.length === 0) {
root.children = []
return root
}
const rootFrame: DirectoryFrame<TEntry> = {
result: root,
entries: rootEntries,
retainedBytes: rootAdmission.retainedBytes,
retired: false,
nextIndex: 0,
remainingChildren: rootEntries.length,
childResults: Array.from({ length: rootEntries.length }, () => undefined)
}
const availableFrames: DirectoryFrame<TEntry>[] = [rootFrame]
const waiters = new Set<() => void>()
let outstandingEntries = rootEntries.length
let fatalError: unknown = null
const wakeWorkers = (): void => {
for (const wake of waiters) {
wake()
}
}
const fail = (error: unknown): void => {
fatalError ??= error
wakeWorkers()
}
const onAbort = (): void => fail(options.createCancellationError())
options.signal?.addEventListener('abort', onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
}
// Why: once every entry is dispatched the listing is dead weight, so drop it
// and hand its charge back before the walk descends any further.
const retireFrame = (frame: DirectoryFrame<TEntry>): void => {
if (frame.retired) {
return
}
frame.retired = true
releaseWorkspaceSpaceScanEntries(budget, frame.entries.length, frame.retainedBytes)
frame.entries = []
frame.retainedBytes = 0
}
const takeAvailableJob = (): EntryJob<TEntry> | null => {
while (availableFrames.length > 0) {
const frame = availableFrames.at(-1)!
if (frame.nextIndex >= frame.entries.length) {
availableFrames.pop()
retireFrame(frame)
continue
}
const index = frame.nextIndex
frame.nextIndex += 1
const entry = frame.entries[index]
const name = options.entryName(entry)
const path = options.joinPath(frame.result.path, name)
if (frame.nextIndex >= frame.entries.length) {
availableFrames.pop()
retireFrame(frame)
}
return { frame, index, entry, name, path }
}
return null
}
const waitForJob = async (): Promise<EntryJob<TEntry> | null> => {
while (fatalError === null) {
options.checkCancelled()
const job = takeAvailableJob()
if (job) {
return job
}
if (outstandingEntries === 0) {
return null
}
await new Promise<void>((resolve) => {
const wake = (): void => {
waiters.delete(wake)
resolve()
}
waiters.add(wake)
})
}
return null
}
const completeChild = (
initialFrame: DirectoryFrame<TEntry>,
initialIndex: number,
initialResult: WorkspaceSpaceEntryScan | null
): void => {
let frame = initialFrame
let index = initialIndex
let result = initialResult
while (true) {
if (frame.childResults) {
frame.childResults[index] = result
}
if (result) {
frame.result.sizeBytes += result.sizeBytes
frame.result.skippedEntryCount += result.skippedEntryCount
} else {
frame.result.skippedEntryCount += 1
}
frame.remainingChildren -= 1
outstandingEntries -= 1
if (frame.remainingChildren > 0) {
break
}
if (frame.childResults) {
frame.result.children = frame.childResults.filter(
(child): child is WorkspaceSpaceEntryScan => child != null
)
}
if (!frame.parentSlot) {
break
}
result = frame.result
index = frame.parentSlot.index
frame = frame.parentSlot.frame
}
wakeWorkers()
}
const expandDirectory = (
job: EntryJob<TEntry>,
result: WorkspaceSpaceEntryScan,
admission: WorkspaceSpaceDirectoryAdmission<TEntry>
): void => {
const entries = admission.entries
if (entries.length === 0) {
completeChild(job.frame, job.index, result)
return
}
outstandingEntries += entries.length
availableFrames.push({
result,
entries,
retainedBytes: admission.retainedBytes,
retired: false,
nextIndex: 0,
remainingChildren: entries.length,
parentSlot: { frame: job.frame, index: job.index }
})
wakeWorkers()
}
const processJob = async (job: EntryJob<TEntry>): Promise<void> => {
let identity: WorkspaceSpaceEntryIdentity
try {
identity = await options.classifyEntry(job.path, job.entry)
options.checkCancelled()
} catch (error) {
if (options.isCancellationError(error)) {
throw error
}
completeChild(job.frame, job.index, null)
return
}
const result = createEntryScan(job.path, job.name, identity)
if (result.kind !== 'directory') {
completeChild(job.frame, job.index, result)
return
}
const admission = await readDirectoryOrNull(job.path, options, budget)
if (admission === null) {
result.skippedEntryCount = 1
completeChild(job.frame, job.index, result)
return
}
expandDirectory(job, result, admission)
}
const worker = async (): Promise<void> => {
while (fatalError === null) {
let job: EntryJob<TEntry> | null
try {
job = await waitForJob()
} catch (error) {
fail(error)
return
}
if (!job) {
return
}
try {
await processJob(job)
} catch (error) {
fail(error)
return
}
}
}
const workerCount = Math.max(1, Math.floor(options.concurrency))
try {
await Promise.all(Array.from({ length: workerCount }, worker))
} finally {
options.signal?.removeEventListener('abort', onAbort)
wakeWorkers()
}
if (fatalError !== null) {
throw fatalError
}
return root
}