Skip to content

Commit 553311a

Browse files
committed
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
1 parent 8e4c0ae commit 553311a

File tree

4 files changed

+233
-2
lines changed

4 files changed

+233
-2
lines changed

src/core/environment/getEnvironmentDetails.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
1717
import { Terminal } from "../../integrations/terminal/Terminal"
1818
import { arePathsEqual } from "../../utils/path"
1919
import { formatResponse } from "../prompts/responses"
20+
import { getProjectFileLimit } from "../../utils/projectDetection"
2021

2122
import { Task } from "../task/Task"
2223
import { formatReminderSection } from "./reminder"
@@ -252,7 +253,15 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
252253
if (maxFiles === 0) {
253254
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
254255
} else {
255-
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
256+
// Apply project-specific file limits to prevent memory issues
257+
const effectiveLimit = await getProjectFileLimit(cline.cwd, maxFiles)
258+
259+
// If we're using a reduced limit for a Swift project, add a note
260+
if (effectiveLimit < maxFiles) {
261+
details += `\n(Note: File listing limited to ${effectiveLimit} files for Swift project memory optimization)`
262+
}
263+
264+
const [files, didHitLimit] = await listFiles(cline.cwd, true, effectiveLimit)
256265
const { showRooIgnoredFiles = true } = state ?? {}
257266

258267
const result = formatResponse.formatFilesList(

src/services/glob/list-files.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,9 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
627627
let output = ""
628628
let results: string[] = []
629629

630+
// Use a smaller buffer size to process data more frequently and reduce memory usage
631+
const MAX_BUFFER_SIZE = 1024 * 64 // 64KB buffer instead of unlimited
632+
630633
// Set timeout to avoid hanging
631634
const timeoutId = setTimeout(() => {
632635
rgProcess.kill()
@@ -637,7 +640,11 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
637640
// Process stdout data as it comes in
638641
rgProcess.stdout.on("data", (data) => {
639642
output += data.toString()
640-
processRipgrepOutput()
643+
644+
// Process output more frequently to avoid large memory buffers
645+
if (output.length > MAX_BUFFER_SIZE || output.split("\n").length > 100) {
646+
processRipgrepOutput()
647+
}
641648

642649
// Kill the process if we've reached the limit
643650
if (results.length >= limit) {
@@ -691,6 +698,8 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
691698
// Keep the relative path as returned by ripgrep
692699
results.push(line)
693700
} else if (results.length >= limit) {
701+
// Clear the output buffer when we hit the limit to free memory
702+
output = ""
694703
break
695704
}
696705
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import * as fs from "fs"
3+
import * as path from "path"
4+
import { isSwiftProject, getProjectFileLimit } from "../projectDetection"
5+
6+
vi.mock("fs", () => ({
7+
promises: {
8+
readdir: vi.fn(),
9+
stat: vi.fn(),
10+
},
11+
}))
12+
13+
describe("projectDetection", () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks()
16+
})
17+
18+
afterEach(() => {
19+
vi.restoreAllMocks()
20+
})
21+
22+
describe("isSwiftProject", () => {
23+
it("should detect Swift project with Package.swift", async () => {
24+
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources", "Tests"] as any)
25+
26+
const result = await isSwiftProject("/test/project")
27+
expect(result).toBe(true)
28+
})
29+
30+
it("should detect Swift project with .xcodeproj", async () => {
31+
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj", "MyApp", "MyAppTests"] as any)
32+
33+
const result = await isSwiftProject("/test/project")
34+
expect(result).toBe(true)
35+
})
36+
37+
it("should detect Swift project with .xcworkspace", async () => {
38+
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcworkspace", "MyApp.xcodeproj", "Pods"] as any)
39+
40+
const result = await isSwiftProject("/test/project")
41+
expect(result).toBe(true)
42+
})
43+
44+
it("should detect Swift project with Podfile", async () => {
45+
vi.mocked(fs.promises.readdir).mockResolvedValue(["Podfile", "Podfile.lock", "MyApp"] as any)
46+
47+
const result = await isSwiftProject("/test/project")
48+
expect(result).toBe(true)
49+
})
50+
51+
it("should detect Swift project with Cartfile", async () => {
52+
vi.mocked(fs.promises.readdir).mockResolvedValue(["Cartfile", "Cartfile.resolved", "MyApp"] as any)
53+
54+
const result = await isSwiftProject("/test/project")
55+
expect(result).toBe(true)
56+
})
57+
58+
it("should detect Swift project with .swift files in root", async () => {
59+
vi.mocked(fs.promises.readdir).mockResolvedValue(["main.swift", "AppDelegate.swift", "README.md"] as any)
60+
61+
const result = await isSwiftProject("/test/project")
62+
expect(result).toBe(true)
63+
})
64+
65+
it("should detect Swift project with Sources directory containing Swift files", async () => {
66+
vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["Sources", "Tests", "README.md"] as any)
67+
68+
vi.mocked(fs.promises.stat).mockResolvedValue({
69+
isDirectory: () => true,
70+
} as any)
71+
72+
vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["App.swift", "Model.swift"] as any)
73+
74+
const result = await isSwiftProject("/test/project")
75+
expect(result).toBe(true)
76+
})
77+
78+
it("should not detect non-Swift project", async () => {
79+
vi.mocked(fs.promises.readdir).mockResolvedValue([
80+
"package.json",
81+
"node_modules",
82+
"src",
83+
"README.md",
84+
] as any)
85+
86+
const result = await isSwiftProject("/test/project")
87+
expect(result).toBe(false)
88+
})
89+
90+
it("should handle errors gracefully", async () => {
91+
vi.mocked(fs.promises.readdir).mockRejectedValue(new Error("Permission denied"))
92+
93+
const result = await isSwiftProject("/test/project")
94+
expect(result).toBe(false)
95+
})
96+
})
97+
98+
describe("getProjectFileLimit", () => {
99+
it("should return reduced limit for Swift projects", async () => {
100+
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources"] as any)
101+
102+
const result = await getProjectFileLimit("/test/project", 200)
103+
expect(result).toBe(100)
104+
})
105+
106+
it("should return reduced limit not exceeding 100 for Swift projects", async () => {
107+
vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj"] as any)
108+
109+
const result = await getProjectFileLimit("/test/project", 500)
110+
expect(result).toBe(100)
111+
})
112+
113+
it("should return default limit for non-Swift projects", async () => {
114+
vi.mocked(fs.promises.readdir).mockResolvedValue(["package.json", "src"] as any)
115+
116+
const result = await getProjectFileLimit("/test/project", 200)
117+
expect(result).toBe(200)
118+
})
119+
120+
it("should return smaller default if it's less than 100 for Swift projects", async () => {
121+
vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift"] as any)
122+
123+
const result = await getProjectFileLimit("/test/project", 50)
124+
expect(result).toBe(50)
125+
})
126+
})
127+
})

src/utils/projectDetection.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as fs from "fs"
2+
import * as path from "path"
3+
4+
/**
5+
* Detects if a directory contains a Swift project
6+
* @param dirPath - The directory path to check
7+
* @returns true if it's a Swift project, false otherwise
8+
*/
9+
export async function isSwiftProject(dirPath: string): Promise<boolean> {
10+
try {
11+
// Check for common Swift project indicators
12+
const swiftIndicators = [
13+
"Package.swift", // Swift Package Manager
14+
"*.xcodeproj", // Xcode project
15+
"*.xcworkspace", // Xcode workspace
16+
"Podfile", // CocoaPods
17+
"Cartfile", // Carthage
18+
]
19+
20+
const entries = await fs.promises.readdir(dirPath)
21+
22+
for (const entry of entries) {
23+
// Check for exact matches
24+
if (swiftIndicators.includes(entry)) {
25+
return true
26+
}
27+
28+
// Check for pattern matches (e.g., *.xcodeproj)
29+
for (const indicator of swiftIndicators) {
30+
if (indicator.includes("*")) {
31+
const pattern = indicator.replace("*", "")
32+
if (entry.endsWith(pattern)) {
33+
return true
34+
}
35+
}
36+
}
37+
}
38+
39+
// Also check if there are .swift files in the root directory
40+
const swiftFiles = entries.filter((entry) => entry.endsWith(".swift"))
41+
if (swiftFiles.length > 0) {
42+
return true
43+
}
44+
45+
// Check for iOS/macOS specific directories
46+
const iosIndicators = ["Sources", "Tests", "UITests"]
47+
for (const indicator of iosIndicators) {
48+
const indicatorPath = path.join(dirPath, indicator)
49+
try {
50+
const stats = await fs.promises.stat(indicatorPath)
51+
if (stats.isDirectory()) {
52+
// Check if this directory contains Swift files
53+
const subEntries = await fs.promises.readdir(indicatorPath)
54+
const hasSwiftFiles = subEntries.some((entry) => entry.endsWith(".swift"))
55+
if (hasSwiftFiles) {
56+
return true
57+
}
58+
}
59+
} catch {
60+
// Directory doesn't exist, continue checking
61+
}
62+
}
63+
64+
return false
65+
} catch (error) {
66+
console.error(`Error detecting Swift project: ${error}`)
67+
return false
68+
}
69+
}
70+
71+
/**
72+
* Gets the recommended file limit for a project based on its type
73+
* @param dirPath - The directory path to check
74+
* @param defaultLimit - The default limit to use
75+
* @returns The recommended file limit
76+
*/
77+
export async function getProjectFileLimit(dirPath: string, defaultLimit: number): Promise<number> {
78+
// For Swift projects, use a more conservative limit to prevent memory issues
79+
if (await isSwiftProject(dirPath)) {
80+
// Swift projects often have large dependency trees and generated files
81+
// Use a smaller limit to prevent memory exhaustion
82+
return Math.min(defaultLimit, 100)
83+
}
84+
85+
return defaultLimit
86+
}

0 commit comments

Comments
 (0)