fix: medium severity audit fixes — races, leaks, perf, warnings

- Clear teams/teamTasks maps in handleInit() on SSE reconnect
- Fix saveNowAsync() race condition with in-flight promise guard
- Add setMaxListeners() on Session, WebServer, SubagentWatcher,
  ImageWatcher, TmuxManager to prevent MaxListenersExceeded warnings
- Debounce persistSessionState() per-session (100ms) to reduce
  redundant toState() serialization across 25+ call sites
- Cache getLightSessionsState() with 1s TTL to avoid re-serializing
  all sessions on every SSE connect and /api/sessions request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-15 05:33:34 +01:00
co-authored by Claude Opus 4.6
parent f783dd91f4
commit 9ab62adfa8
7 changed files with 69 additions and 3 deletions
+1
View File
@@ -87,6 +87,7 @@ export class ImageWatcher extends EventEmitter {
constructor() {
super();
this.setMaxListeners(50);
}
// ========== Public API ==========
+1
View File
@@ -369,6 +369,7 @@ export class Session extends EventEmitter {
niceConfig?: NiceConfig; // Nice prioritying configuration
}) {
super();
this.setMaxListeners(25);
// Default error handler prevents unhandled 'error' events from crashing the process.
// Server attaches its own handler after construction — this is a safety net for the gap.
+20
View File
@@ -64,6 +64,9 @@ export class StateStore {
private consecutiveSaveFailures: number = 0;
private circuitBreakerOpen: boolean = false;
// Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write)
private _saveInFlight: Promise<void> | null = null;
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), '.claudeman', 'state.json');
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
@@ -130,8 +133,25 @@ export class StateStore {
* Async version of saveNow — used by the debounced save() path.
* Uses non-blocking fs.promises to avoid blocking the event loop during
* the debounced write cycle. For synchronous shutdown flush, use saveNow().
*
* Guards against concurrent execution: if a save is already in flight,
* waits for it to complete then re-checks dirty flag before starting another.
*/
async saveNowAsync(): Promise<void> {
if (this._saveInFlight) {
await this._saveInFlight;
// After waiting, re-check if still dirty (the previous save may have handled it)
if (!this.dirty) return;
}
this._saveInFlight = this._doSaveAsync();
try {
await this._saveInFlight;
} finally {
this._saveInFlight = null;
}
}
private async _doSaveAsync(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
+1
View File
@@ -177,6 +177,7 @@ export class SubagentWatcher extends EventEmitter {
constructor() {
super();
this.setMaxListeners(50);
}
/**
+1
View File
@@ -141,6 +141,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
constructor() {
super();
this.setMaxListeners(50);
if (!IS_TEST_MODE) {
this.loadSessions();
}
+2
View File
@@ -3097,6 +3097,8 @@ class ClaudemanApp {
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.projectInsights.clear();
this.teams.clear();
this.teamTasks.clear();
// Clear all idle timers to prevent stale timers from firing
for (const timer of this.idleTimers.values()) {
clearTimeout(timer);
+43 -3
View File
@@ -115,6 +115,8 @@ const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
// State update debounce interval (batch expensive toDetailedState() calls)
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
// Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call
const SESSIONS_LIST_CACHE_TTL = 1000;
// Scheduled runs cleanup interval (check every 5 minutes)
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
// Completed scheduled runs max age (1 hour)
@@ -373,6 +375,8 @@ export class WebServer extends EventEmitter {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private cachedLightState: { data: Record<string, unknown>; timestamp: number } | null = null;
private static readonly LIGHT_STATE_CACHE_TTL_MS = 1000;
// Cached sessions list for getLightSessionsState() (avoids re-serializing all sessions on every call)
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
// Token recording for daily stats (track what's been recorded to avoid double-counting)
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private tokenRecordingTimer: NodeJS.Timeout | null = null;
@@ -382,6 +386,7 @@ export class WebServer extends EventEmitter {
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
// Active plan orchestrators (for cancellation via API)
private activePlanOrchestrators: Map<string, PlanOrchestrator> = new Map();
private persistDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
// Grace period before starting restored respawn controllers (2 minutes)
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
// Stored listener handlers for cleanup
@@ -401,6 +406,7 @@ export class WebServer extends EventEmitter {
} | null = null;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
super();
this.setMaxListeners(0);
this.port = port;
this.https = https;
this.testMode = testMode;
@@ -3438,8 +3444,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
/** Persists full session state including respawn config to state.json */
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
private persistSessionState(session: Session): void {
const existing = this.persistDebounceTimers.get(session.id);
if (existing) clearTimeout(existing);
this.persistDebounceTimers.set(session.id, setTimeout(() => {
this.persistDebounceTimers.delete(session.id);
// Session may have been removed during debounce
if (this.sessions.has(session.id)) {
this._persistSessionStateNow(session);
}
}, 100));
}
/** Persists full session state including respawn config to state.json */
private _persistSessionStateNow(session: Session): void {
const state = session.toState();
const controller = this.respawnControllers.get(session.id);
if (controller) {
@@ -4341,9 +4360,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
* on-demand when switching tabs via /api/sessions/:id/buffer
*/
private getLightSessionsState() {
const now = Date.now();
if (this.cachedSessionsList && (now - this.cachedSessionsList.timestamp) < SESSIONS_LIST_CACHE_TTL) {
return this.cachedSessionsList.data;
}
// getSessionStateWithRespawn already uses toLightDetailedState() which
// excludes terminalBuffer and textOutput — no extra stripping needed
return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s));
const data = Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s));
this.cachedSessionsList = { data, timestamp: now };
return data;
}
// Clean up old completed scheduled runs
@@ -4449,9 +4474,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private broadcast(event: string, data: unknown): void {
// Invalidate light state cache on any state-changing broadcast
// Invalidate caches on any state-changing broadcast
if (event.startsWith('session:') || event === 'respawn:') {
this.cachedLightState = null;
this.cachedSessionsList = null;
}
// Performance optimization: serialize JSON once for all clients
let message: string;
@@ -5000,6 +5026,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop multiplexer and flush pending saves
this.mux.destroy();
// Flush any pending persist-debounce timers and persist dirty sessions
for (const [sessionId, timer] of this.persistDebounceTimers) {
clearTimeout(timer);
const session = this.sessions.get(sessionId);
if (session) {
this._persistSessionStateNow(session);
}
}
this.persistDebounceTimers.clear();
// Clear cached state
this.cachedLightState = null;
this.cachedSessionsList = null;
// Clear all pending respawn start timers (from restoration grace period)
for (const timer of this.pendingRespawnStarts.values()) {
clearTimeout(timer);