Skip to content

Commit a2030b8

Browse files
committed
fix: improve memory leak prevention in Task and ClineProvider disposal
- Ensure RooIgnoreController is always disposed even if errors occur - Add finally block to guarantee reference cleanup - Improve event listener cleanup order in ClineProvider - Add defensive error handling for all disposal operations - Clear array references to help garbage collection - Fix TypeScript error in removeClineFromStack method Fixes #7561
1 parent 63b71d8 commit a2030b8

File tree

3 files changed

+131
-41
lines changed

3 files changed

+131
-41
lines changed

src/core/task/Task.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,11 +1425,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
14251425
public dispose(): void {
14261426
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
14271427

1428-
// Remove all event listeners to prevent memory leaks.
1428+
// Remove all event listeners first to prevent any callbacks during disposal
14291429
try {
14301430
this.removeAllListeners()
14311431
} catch (error) {
1432-
console.error("Error removing event listeners:", error)
1432+
console.error(`[Task#dispose] Error removing event listeners for task ${this.taskId}:`, error)
14331433
}
14341434

14351435
// Stop waiting for child task completion.
@@ -1438,60 +1438,78 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
14381438
this.pauseInterval = undefined
14391439
}
14401440

1441+
// Unsubscribe from bridge if enabled
14411442
if (this.enableBridge) {
14421443
BridgeOrchestrator.getInstance()
14431444
?.unsubscribeFromTask(this.taskId)
14441445
.catch((error) =>
14451446
console.error(
1446-
`[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`,
1447+
`[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed for task ${this.taskId}: ${error instanceof Error ? error.message : String(error)}`,
14471448
),
14481449
)
14491450
}
14501451

14511452
// Release any terminals associated with this task.
14521453
try {
1453-
// Release any terminals associated with this task.
14541454
TerminalRegistry.releaseTerminalsForTask(this.taskId)
14551455
} catch (error) {
1456-
console.error("Error releasing terminals:", error)
1456+
console.error(`[Task#dispose] Error releasing terminals for task ${this.taskId}:`, error)
14571457
}
14581458

1459+
// Close browsers
14591460
try {
14601461
this.urlContentFetcher.closeBrowser()
14611462
} catch (error) {
1462-
console.error("Error closing URL content fetcher browser:", error)
1463+
console.error(`[Task#dispose] Error closing URL content fetcher browser for task ${this.taskId}:`, error)
14631464
}
14641465

14651466
try {
14661467
this.browserSession.closeBrowser()
14671468
} catch (error) {
1468-
console.error("Error closing browser session:", error)
1469+
console.error(`[Task#dispose] Error closing browser session for task ${this.taskId}:`, error)
14691470
}
14701471

1471-
try {
1472-
if (this.rooIgnoreController) {
1472+
// CRITICAL: Always dispose RooIgnoreController to prevent memory leaks
1473+
// This must be done even if it throws an error
1474+
if (this.rooIgnoreController) {
1475+
try {
14731476
this.rooIgnoreController.dispose()
1477+
} catch (error) {
1478+
console.error(
1479+
`[Task#dispose] CRITICAL: Error disposing RooIgnoreController for task ${this.taskId}:`,
1480+
error,
1481+
)
1482+
} finally {
1483+
// Always clear the reference to allow garbage collection
14741484
this.rooIgnoreController = undefined
14751485
}
1476-
} catch (error) {
1477-
console.error("Error disposing RooIgnoreController:", error)
1478-
// This is the critical one for the leak fix.
14791486
}
14801487

1488+
// Dispose file context tracker
14811489
try {
14821490
this.fileContextTracker.dispose()
14831491
} catch (error) {
1484-
console.error("Error disposing file context tracker:", error)
1492+
console.error(`[Task#dispose] Error disposing file context tracker for task ${this.taskId}:`, error)
14851493
}
14861494

1495+
// Revert any pending diff changes
14871496
try {
1488-
// If we're not streaming then `abortStream` won't be called.
14891497
if (this.isStreaming && this.diffViewProvider.isEditing) {
1490-
this.diffViewProvider.revertChanges().catch(console.error)
1498+
this.diffViewProvider
1499+
.revertChanges()
1500+
.catch((error) =>
1501+
console.error(`[Task#dispose] Error reverting diff changes for task ${this.taskId}:`, error),
1502+
)
14911503
}
14921504
} catch (error) {
1493-
console.error("Error reverting diff changes:", error)
1505+
console.error(`[Task#dispose] Error checking/reverting diff changes for task ${this.taskId}:`, error)
14941506
}
1507+
1508+
// Clear any remaining references to help garbage collection
1509+
this.assistantMessageContent = []
1510+
this.userMessageContent = []
1511+
this.apiConversationHistory = []
1512+
this.clineMessages = []
14951513
}
14961514

14971515
public async abortTask(isAbandoned = false) {

src/core/task/__tests__/Task.dispose.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,11 @@ describe("Task dispose method", () => {
117117
// Call dispose - should not throw
118118
expect(() => task.dispose()).not.toThrow()
119119

120-
// Verify error was logged
121-
expect(consoleErrorSpy).toHaveBeenCalledWith("Error removing event listeners:", expect.any(Error))
120+
// Verify error was logged with the improved format
121+
expect(consoleErrorSpy).toHaveBeenCalledWith(
122+
`[Task#dispose] Error removing event listeners for task ${task.taskId}:`,
123+
expect.any(Error),
124+
)
122125

123126
// Restore
124127
task.removeAllListeners = originalRemoveAllListeners

src/core/webview/ClineProvider.ts

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -350,32 +350,43 @@ export class ClineProvider
350350
}
351351

352352
// Pop the top Cline instance from the stack.
353-
let task = this.clineStack.pop()
353+
const task = this.clineStack.pop()
354354

355355
if (task) {
356-
task.emit(RooCodeEventName.TaskUnfocused)
356+
const taskId = task.taskId
357+
const instanceId = task.instanceId
357358

359+
// Emit unfocused event before cleanup
358360
try {
359-
// Abort the running task and set isAbandoned to true so
360-
// all running promises will exit as well.
361-
await task.abortTask(true)
362-
} catch (e) {
363-
this.log(
364-
`[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`,
365-
)
361+
task.emit(RooCodeEventName.TaskUnfocused)
362+
} catch (error) {
363+
this.log(`[ClineProvider#removeClineFromStack] Error emitting TaskUnfocused for ${taskId}: ${error}`)
366364
}
367365

368-
// Remove event listeners before clearing the reference.
366+
// Remove event listeners BEFORE aborting to prevent any callbacks during cleanup
369367
const cleanupFunctions = this.taskEventListeners.get(task)
370-
371368
if (cleanupFunctions) {
372-
cleanupFunctions.forEach((cleanup) => cleanup())
369+
cleanupFunctions.forEach((cleanup) => {
370+
try {
371+
cleanup()
372+
} catch (error) {
373+
this.log(
374+
`[ClineProvider#removeClineFromStack] Error cleaning up event listener for ${taskId}: ${error}`,
375+
)
376+
}
377+
})
373378
this.taskEventListeners.delete(task)
374379
}
375380

376-
// Make sure no reference kept, once promises end it will be
377-
// garbage collected.
378-
task = undefined
381+
try {
382+
// Abort the running task and set isAbandoned to true so
383+
// all running promises will exit as well.
384+
await task.abortTask(true)
385+
} catch (e) {
386+
this.log(
387+
`[ClineProvider#removeClineFromStack] abortTask() failed ${taskId}.${instanceId}: ${e.message}`,
388+
)
389+
}
379390
}
380391
}
381392

@@ -418,13 +429,31 @@ export class ClineProvider
418429
async dispose() {
419430
this.log("Disposing ClineProvider...")
420431

432+
// Clear all event listeners for all tasks first
433+
for (const task of this.clineStack) {
434+
const cleanupFunctions = this.taskEventListeners.get(task)
435+
if (cleanupFunctions) {
436+
cleanupFunctions.forEach((cleanup) => {
437+
try {
438+
cleanup()
439+
} catch (error) {
440+
this.log(`[ClineProvider#dispose] Error cleaning up event listener: ${error}`)
441+
}
442+
})
443+
this.taskEventListeners.delete(task)
444+
}
445+
}
446+
421447
// Clear all tasks from the stack.
422448
while (this.clineStack.length > 0) {
423449
await this.removeClineFromStack()
424450
}
425451

426452
this.log("Cleared all tasks")
427453

454+
// Clear the task event listeners map completely
455+
this.taskEventListeners = new WeakMap()
456+
428457
if (this.view && "dispose" in this.view) {
429458
this.view.dispose()
430459
this.log("Disposed webview")
@@ -441,21 +470,61 @@ export class ClineProvider
441470
const x = this.disposables.pop()
442471

443472
if (x) {
444-
x.dispose()
473+
try {
474+
x.dispose()
475+
} catch (error) {
476+
this.log(`[ClineProvider#dispose] Error disposing disposable: ${error}`)
477+
}
478+
}
479+
}
480+
481+
// Clean up workspace tracker
482+
if (this._workspaceTracker) {
483+
try {
484+
this._workspaceTracker.dispose()
485+
} catch (error) {
486+
this.log(`[ClineProvider#dispose] Error disposing workspace tracker: ${error}`)
487+
}
488+
this._workspaceTracker = undefined
489+
}
490+
491+
// Unregister from MCP hub
492+
if (this.mcpHub) {
493+
try {
494+
await this.mcpHub.unregisterClient()
495+
} catch (error) {
496+
this.log(`[ClineProvider#dispose] Error unregistering MCP client: ${error}`)
497+
}
498+
this.mcpHub = undefined
499+
}
500+
501+
// Clean up marketplace manager
502+
if (this.marketplaceManager) {
503+
try {
504+
this.marketplaceManager.cleanup()
505+
} catch (error) {
506+
this.log(`[ClineProvider#dispose] Error cleaning up marketplace manager: ${error}`)
507+
}
508+
}
509+
510+
// Dispose custom modes manager
511+
if (this.customModesManager) {
512+
try {
513+
this.customModesManager.dispose()
514+
} catch (error) {
515+
this.log(`[ClineProvider#dispose] Error disposing custom modes manager: ${error}`)
445516
}
446517
}
447518

448-
this._workspaceTracker?.dispose()
449-
this._workspaceTracker = undefined
450-
await this.mcpHub?.unregisterClient()
451-
this.mcpHub = undefined
452-
this.marketplaceManager?.cleanup()
453-
this.customModesManager?.dispose()
454519
this.log("Disposed all disposables")
455520
ClineProvider.activeInstances.delete(this)
456521

457522
// Clean up any event listeners attached to this provider
458-
this.removeAllListeners()
523+
try {
524+
this.removeAllListeners()
525+
} catch (error) {
526+
this.log(`[ClineProvider#dispose] Error removing provider event listeners: ${error}`)
527+
}
459528

460529
McpServerManager.unregisterProvider(this)
461530
}

0 commit comments

Comments
 (0)