fix: optimize memory usage for Swift project analysis

- Add Swift project detection utility to identify Swift/iOS projects
- Implement reduced file limits (max 100 files) for Swift projects to prevent OOM
- Optimize ripgrep buffer processing with smaller chunks (64KB) for better memory management
- Add streaming improvements to process file listings more efficiently
- Add comprehensive tests for Swift project detection

This fixes the out-of-memory crashes when analyzing large Swift projects
by limiting the number of files loaded into memory at once.

Fixes #7345
This commit is contained in:
Roo Code 2025-08-23 00:51:42 +00:00
parent 8e4c0ae99d
commit 553311ae42
4 changed files with 233 additions and 2 deletions

View file

@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
import { arePathsEqual } from "../../utils/path"
import { formatResponse } from "../prompts/responses"
import { getProjectFileLimit } from "../../utils/projectDetection"
import { Task } from "../task/Task"
import { formatReminderSection } from "./reminder"
@ -252,7 +253,15 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
if (maxFiles === 0) {
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
} else {
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
// Apply project-specific file limits to prevent memory issues
const effectiveLimit = await getProjectFileLimit(cline.cwd, maxFiles)
// If we're using a reduced limit for a Swift project, add a note
if (effectiveLimit < maxFiles) {
details += `\n(Note: File listing limited to ${effectiveLimit} files for Swift project memory optimization)`
}
const [files, didHitLimit] = await listFiles(cline.cwd, true, effectiveLimit)
const { showRooIgnoredFiles = true } = state ?? {}
const result = formatResponse.formatFilesList(

View file

@ -627,6 +627,9 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
let output = ""
let results: string[] = []
// Use a smaller buffer size to process data more frequently and reduce memory usage
const MAX_BUFFER_SIZE = 1024 * 64 // 64KB buffer instead of unlimited
// Set timeout to avoid hanging
const timeoutId = setTimeout(() => {
rgProcess.kill()
@ -637,7 +640,11 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
// Process stdout data as it comes in
rgProcess.stdout.on("data", (data) => {
output += data.toString()
processRipgrepOutput()
// Process output more frequently to avoid large memory buffers
if (output.length > MAX_BUFFER_SIZE || output.split("\n").length > 100) {
processRipgrepOutput()
}
// Kill the process if we've reached the limit
if (results.length >= limit) {
@ -691,6 +698,8 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
// Keep the relative path as returned by ripgrep
results.push(line)
} else if (results.length >= limit) {
// Clear the output buffer when we hit the limit to free memory
output = ""
break
}
}

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as fs from "fs"
import * as path from "path"
import { isSwiftProject, getProjectFileLimit } from "../projectDetection"
vi.mock("fs", () => ({
promises: {
readdir: vi.fn(),
stat: vi.fn(),
},
}))
describe("projectDetection", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("isSwiftProject", () => {
it("should detect Swift project with Package.swift", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources", "Tests"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with .xcodeproj", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj", "MyApp", "MyAppTests"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with .xcworkspace", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcworkspace", "MyApp.xcodeproj", "Pods"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with Podfile", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["Podfile", "Podfile.lock", "MyApp"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with Cartfile", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["Cartfile", "Cartfile.resolved", "MyApp"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with .swift files in root", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["main.swift", "AppDelegate.swift", "README.md"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should detect Swift project with Sources directory containing Swift files", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["Sources", "Tests", "README.md"] as any)
vi.mocked(fs.promises.stat).mockResolvedValue({
isDirectory: () => true,
} as any)
vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["App.swift", "Model.swift"] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(true)
})
it("should not detect non-Swift project", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue([
"package.json",
"node_modules",
"src",
"README.md",
] as any)
const result = await isSwiftProject("/test/project")
expect(result).toBe(false)
})
it("should handle errors gracefully", async () => {
vi.mocked(fs.promises.readdir).mockRejectedValue(new Error("Permission denied"))
const result = await isSwiftProject("/test/project")
expect(result).toBe(false)
})
})
describe("getProjectFileLimit", () => {
it("should return reduced limit for Swift projects", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources"] as any)
const result = await getProjectFileLimit("/test/project", 200)
expect(result).toBe(100)
})
it("should return reduced limit not exceeding 100 for Swift projects", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj"] as any)
const result = await getProjectFileLimit("/test/project", 500)
expect(result).toBe(100)
})
it("should return default limit for non-Swift projects", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["package.json", "src"] as any)
const result = await getProjectFileLimit("/test/project", 200)
expect(result).toBe(200)
})
it("should return smaller default if it's less than 100 for Swift projects", async () => {
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift"] as any)
const result = await getProjectFileLimit("/test/project", 50)
expect(result).toBe(50)
})
})
})

View file

@ -0,0 +1,86 @@
import * as fs from "fs"
import * as path from "path"
/**
* Detects if a directory contains a Swift project
* @param dirPath - The directory path to check
* @returns true if it's a Swift project, false otherwise
*/
export async function isSwiftProject(dirPath: string): Promise<boolean> {
try {
// Check for common Swift project indicators
const swiftIndicators = [
"Package.swift", // Swift Package Manager
"*.xcodeproj", // Xcode project
"*.xcworkspace", // Xcode workspace
"Podfile", // CocoaPods
"Cartfile", // Carthage
]
const entries = await fs.promises.readdir(dirPath)
for (const entry of entries) {
// Check for exact matches
if (swiftIndicators.includes(entry)) {
return true
}
// Check for pattern matches (e.g., *.xcodeproj)
for (const indicator of swiftIndicators) {
if (indicator.includes("*")) {
const pattern = indicator.replace("*", "")
if (entry.endsWith(pattern)) {
return true
}
}
}
}
// Also check if there are .swift files in the root directory
const swiftFiles = entries.filter((entry) => entry.endsWith(".swift"))
if (swiftFiles.length > 0) {
return true
}
// Check for iOS/macOS specific directories
const iosIndicators = ["Sources", "Tests", "UITests"]
for (const indicator of iosIndicators) {
const indicatorPath = path.join(dirPath, indicator)
try {
const stats = await fs.promises.stat(indicatorPath)
if (stats.isDirectory()) {
// Check if this directory contains Swift files
const subEntries = await fs.promises.readdir(indicatorPath)
const hasSwiftFiles = subEntries.some((entry) => entry.endsWith(".swift"))
if (hasSwiftFiles) {
return true
}
}
} catch {
// Directory doesn't exist, continue checking
}
}
return false
} catch (error) {
console.error(`Error detecting Swift project: ${error}`)
return false
}
}
/**
* Gets the recommended file limit for a project based on its type
* @param dirPath - The directory path to check
* @param defaultLimit - The default limit to use
* @returns The recommended file limit
*/
export async function getProjectFileLimit(dirPath: string, defaultLimit: number): Promise<number> {
// For Swift projects, use a more conservative limit to prevent memory issues
if (await isSwiftProject(dirPath)) {
// Swift projects often have large dependency trees and generated files
// Use a smaller limit to prevent memory exhaustion
return Math.min(defaultLimit, 100)
}
return defaultLimit
}