fix: prevent memory leaks from orphaned event listeners

- Store TaskTracker and InnerLoopTracker handler references in class fields
- Add cleanupTrackerListeners() method to remove handlers on stop()
- Call cleanup in stop() to prevent listener accumulation
- Fixes memory leak from listener closures capturing session references

This prevents gradual memory creep when sessions are repeatedly
created and destroyed in long-running server instances.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 05:50:09 +01:00
co-authored by Claude Opus 4.5
parent 645f86e9cc
commit 1a7c6d3d0f
+60 -9
View File
@@ -322,6 +322,20 @@ export class Session extends EventEmitter {
// Inner loop tracking (Ralph Wiggum loops and todo lists inside Claude Code)
private _innerLoopTracker: InnerLoopTracker;
// Store handler references for cleanup (prevents memory leaks)
private _taskTrackerHandlers: {
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
} | null = null;
private _innerLoopHandlers: {
loopUpdate: (state: InnerLoopState) => void;
todoUpdate: (todos: InnerTodoItem[]) => void;
completionDetected: (phrase: string) => void;
} | null = null;
constructor(config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
@@ -341,18 +355,29 @@ export class Session extends EventEmitter {
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
this._screenSession = config.screenSession || null; // Use existing screen if provided
// Initialize task tracker and forward events
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
this._taskTracker.on('taskCreated', (task) => this.emit('taskCreated', task));
this._taskTracker.on('taskUpdated', (task) => this.emit('taskUpdated', task));
this._taskTracker.on('taskCompleted', (task) => this.emit('taskCompleted', task));
this._taskTracker.on('taskFailed', (task, error) => this.emit('taskFailed', task, error));
this._taskTrackerHandlers = {
taskCreated: (task) => this.emit('taskCreated', task),
taskUpdated: (task) => this.emit('taskUpdated', task),
taskCompleted: (task) => this.emit('taskCompleted', task),
taskFailed: (task, error) => this.emit('taskFailed', task, error),
};
this._taskTracker.on('taskCreated', this._taskTrackerHandlers.taskCreated);
this._taskTracker.on('taskUpdated', this._taskTrackerHandlers.taskUpdated);
this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted);
this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed);
// Initialize inner loop tracker and forward events
// Initialize inner loop tracker and forward events (store handlers for cleanup)
this._innerLoopTracker = new InnerLoopTracker();
this._innerLoopTracker.on('loopUpdate', (state) => this.emit('innerLoopUpdate', state));
this._innerLoopTracker.on('todoUpdate', (todos) => this.emit('innerTodoUpdate', todos));
this._innerLoopTracker.on('completionDetected', (phrase) => this.emit('innerCompletionDetected', phrase));
this._innerLoopHandlers = {
loopUpdate: (state) => this.emit('innerLoopUpdate', state),
todoUpdate: (todos) => this.emit('innerTodoUpdate', todos),
completionDetected: (phrase) => this.emit('innerCompletionDetected', phrase),
};
this._innerLoopTracker.on('loopUpdate', this._innerLoopHandlers.loopUpdate);
this._innerLoopTracker.on('todoUpdate', this._innerLoopHandlers.todoUpdate);
this._innerLoopTracker.on('completionDetected', this._innerLoopHandlers.completionDetected);
}
get status(): SessionStatus {
@@ -1270,6 +1295,29 @@ export class Session extends EventEmitter {
});
}
/**
* Remove event listeners from TaskTracker and InnerLoopTracker.
* Prevents memory leaks by ensuring handlers don't persist after session stop.
*/
private cleanupTrackerListeners(): void {
// Remove TaskTracker handlers
if (this._taskTrackerHandlers) {
this._taskTracker.off('taskCreated', this._taskTrackerHandlers.taskCreated);
this._taskTracker.off('taskUpdated', this._taskTrackerHandlers.taskUpdated);
this._taskTracker.off('taskCompleted', this._taskTrackerHandlers.taskCompleted);
this._taskTracker.off('taskFailed', this._taskTrackerHandlers.taskFailed);
this._taskTrackerHandlers = null;
}
// Remove InnerLoopTracker handlers
if (this._innerLoopHandlers) {
this._innerLoopTracker.off('loopUpdate', this._innerLoopHandlers.loopUpdate);
this._innerLoopTracker.off('todoUpdate', this._innerLoopHandlers.todoUpdate);
this._innerLoopTracker.off('completionDetected', this._innerLoopHandlers.completionDetected);
this._innerLoopHandlers = null;
}
}
/**
* Stops the session and cleans up resources.
*
@@ -1308,6 +1356,9 @@ export class Session extends EventEmitter {
this.resolvePromise = null;
this.rejectPromise = null;
// Remove event listeners from trackers to prevent memory leaks
this.cleanupTrackerListeners();
if (this.ptyProcess) {
const pid = this.ptyProcess.pid;