fix: reduce CPU load from file watchers and improve performance

- Increased debounce delays to reduce frequency of file system events
- Fixed MCP Hub file watcher cleanup to prevent duplicate watchers
- Optimized ripgrep process handling with early termination
- Improved timeout handling in list-files scanning
- Updated tests to match new debounce timings

Fixes #7595
This commit is contained in:
Roo Code 2025-09-02 18:10:34 +00:00
parent 2e59347a9d
commit 34b81fafc3
5 changed files with 50 additions and 38 deletions

View file

@ -106,7 +106,7 @@ class WorkspaceTracker {
this.prevWorkSpacePath = this.cwd
this.initializeFilePaths()
}
}, 300) // Debounce for 300ms
}, 500) // Increased debounce to 500ms to reduce CPU load
}
private workspaceDidUpdate() {
@ -125,7 +125,7 @@ class WorkspaceTracker {
openedTabs: this.getOpenedTabsInfo(),
})
this.updateTimer = null
}, 300) // Debounce for 300ms
}, 500) // Increased debounce to 500ms to reduce CPU load
}
private normalizeFilePath(filePath: string): string {

View file

@ -225,8 +225,8 @@ describe("WorkspaceTracker", () => {
// Simulate tab change event
await registeredTabChangeCallback!()
// Run the debounce timer for workspaceDidReset
vitest.advanceTimersByTime(300)
// Run the debounce timer for workspaceDidReset (now 500ms)
vitest.advanceTimersByTime(500)
// Should clear file paths and reset workspace
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
@ -314,8 +314,8 @@ describe("WorkspaceTracker", () => {
// Call again before timer completes
await registeredTabChangeCallback!()
// Advance timer
vitest.advanceTimersByTime(300)
// Advance timer (now 500ms)
vitest.advanceTimersByTime(500)
// Should only have one call to postMessageToWebview
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
@ -338,8 +338,8 @@ describe("WorkspaceTracker", () => {
// Dispose before timer completes
workspaceTracker.dispose()
// Advance timer
vitest.advanceTimersByTime(300)
// Advance timer (now 500ms)
vitest.advanceTimersByTime(500)
// Should have called dispose on all disposables
expect(mockDispose).toHaveBeenCalled()

View file

@ -37,7 +37,7 @@ export class FileWatcher implements IFileWatcher {
private ignoreController: RooIgnoreController
private accumulatedEvents: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }> = new Map()
private batchProcessDebounceTimer?: NodeJS.Timeout
private readonly BATCH_DEBOUNCE_DELAY_MS = 500
private readonly BATCH_DEBOUNCE_DELAY_MS = 1000 // Increased from 500ms to reduce CPU load
private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10
private readonly batchSegmentThreshold: number

View file

@ -626,29 +626,42 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
const rgProcess = childProcess.spawn(rgPath, args)
let output = ""
let results: string[] = []
let processKilled = false
// Set timeout to avoid hanging
// Set timeout to avoid hanging - increased timeout for slower systems
const timeoutId = setTimeout(() => {
rgProcess.kill()
console.warn("ripgrep timed out, returning partial results")
resolve(results.slice(0, limit))
}, 10_000)
if (!processKilled) {
processKilled = true
rgProcess.kill("SIGTERM")
console.warn("ripgrep timed out, returning partial results")
resolve(results.slice(0, limit))
}
}, 15_000) // Increased timeout to 15 seconds
// Process stdout data as it comes in
rgProcess.stdout.on("data", (data) => {
// Early exit if we've already killed the process
if (processKilled) return
output += data.toString()
processRipgrepOutput()
// Kill the process if we've reached the limit
if (results.length >= limit) {
rgProcess.kill()
clearTimeout(timeoutId) // Clear the timeout when we kill the process due to reaching the limit
if (results.length >= limit && !processKilled) {
processKilled = true
clearTimeout(timeoutId)
rgProcess.kill("SIGTERM")
// Resolve immediately when limit is reached
resolve(results.slice(0, limit))
}
})
// Process stderr but don't fail on non-zero exit codes
rgProcess.stderr.on("data", (data) => {
console.error(`ripgrep stderr: ${data}`)
// Only log errors if not killed intentionally
if (!processKilled) {
console.error(`ripgrep stderr: ${data}`)
}
})
// Handle process completion
@ -656,22 +669,27 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
// Clear the timeout to avoid memory leaks
clearTimeout(timeoutId)
// Process any remaining output
processRipgrepOutput(true)
// Only process if not already resolved
if (!processKilled) {
// Process any remaining output
processRipgrepOutput(true)
// Log non-zero exit codes but don't fail
if (code !== 0 && code !== null && code !== 143 /* SIGTERM */) {
console.warn(`ripgrep process exited with code ${code}, returning partial results`)
// Log non-zero exit codes but don't fail
if (code !== 0 && code !== null && code !== 143 /* SIGTERM */) {
console.warn(`ripgrep process exited with code ${code}, returning partial results`)
}
resolve(results.slice(0, limit))
}
resolve(results.slice(0, limit))
})
// Handle process errors
rgProcess.on("error", (error) => {
// Clear the timeout to avoid memory leaks
clearTimeout(timeoutId)
reject(new Error(`ripgrep process error: ${error.message}`))
if (!processKilled) {
reject(new Error(`ripgrep process error: ${error.message}`))
}
})
// Helper function to process output buffer

View file

@ -286,11 +286,11 @@ export class McpHub {
clearTimeout(existingTimer)
}
// Set new timer
// Set new timer with longer debounce to reduce CPU usage
const timer = setTimeout(async () => {
this.configChangeDebounceTimers.delete(key)
await this.handleConfigFileChange(filePath, source)
}, 500) // 500ms debounce
}, 1000) // Increased to 1000ms debounce to reduce CPU load
this.configChangeDebounceTimers.set(key, timer)
}
@ -1033,7 +1033,6 @@ export class McpHub {
if (manageConnectingState) {
this.isConnecting = true
}
this.removeAllFileWatchers()
// Filter connections by source
const currentConnections = this.connections.filter(
(conn) => conn.server.source === source || (!conn.server.source && source === "global"),
@ -1041,7 +1040,7 @@ export class McpHub {
const currentNames = new Set(currentConnections.map((conn) => conn.server.name))
const newNames = new Set(Object.keys(newServers))
// Delete removed servers
// Delete removed servers (this will also clean up their file watchers)
for (const name of currentNames) {
if (!newNames.has(name)) {
await this.deleteConnection(name, source)
@ -1065,10 +1064,7 @@ export class McpHub {
if (!currentConnection) {
// New server
try {
// Only setup file watcher for enabled servers
if (!validatedConfig.disabled) {
this.setupFileWatcher(name, validatedConfig, source)
}
// connectToServer will handle file watcher setup for enabled servers
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
@ -1076,11 +1072,9 @@ export class McpHub {
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
// Only setup file watcher for enabled servers
if (!validatedConfig.disabled) {
this.setupFileWatcher(name, validatedConfig, source)
}
// Delete connection first (this cleans up old file watchers)
await this.deleteConnection(name, source)
// connectToServer will handle file watcher setup for enabled servers
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error)