perf: optimize for long-running Claude sessions

Critical fixes (unbounded growth):
- Add 64KB line buffer limit with 100ms periodic flush in session.ts
- Add task tracker cleanup (max 100 completed tasks)
- Add scheduled runs auto-cleanup after 1 hour
- Fix session leaks in scheduled run iterations

High priority (performance):
- Add SSE event batching (50ms output, 100ms task updates)
- Parallelize screen stats with Promise.all()
- Add 1MB buffer limit to respawn controller

Medium priority (frontend):
- Make xterm scrollback configurable (default 5000)
- Debounce renderSessionTabs() at 100ms

New feature:
- Add CPU and memory usage display in frontend header
- New /api/system/stats endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 06:51:25 +01:00
co-authored by Claude Opus 4.5
parent 40fd13480d
commit 7979e77924
9 changed files with 389 additions and 19 deletions
+83 -3
View File
@@ -1,4 +1,7 @@
// Claudeman App - Tab-based Terminal UI
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000;
class ClaudemanApp {
constructor() {
this.sessions = new Map();
@@ -20,6 +23,12 @@ class ClaudemanApp {
this.pendingWrites = '';
this.writeFrameScheduled = false;
// Render debouncing
this.renderSessionTabsTimeout = null;
// System stats polling
this.systemStatsInterval = null;
this.init();
}
@@ -32,9 +41,14 @@ class ClaudemanApp {
this.setupEventListeners();
// Show monitor panel by default
this.toggleMonitorPanel();
// Start system stats polling
this.startSystemStatsPolling();
}
initTerminal() {
// Load scrollback setting from localStorage (default 5000)
const scrollback = parseInt(localStorage.getItem('claudeman-scrollback')) || DEFAULT_SCROLLBACK;
this.terminal = new Terminal({
theme: {
background: '#0d0d0d',
@@ -64,7 +78,7 @@ class ClaudemanApp {
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
scrollback: scrollback,
allowTransparency: true,
});
@@ -479,6 +493,16 @@ class ClaudemanApp {
// ========== Session Tabs ==========
renderSessionTabs() {
// Debounce renders at 100ms to prevent excessive DOM updates
if (this.renderSessionTabsTimeout) {
clearTimeout(this.renderSessionTabsTimeout);
}
this.renderSessionTabsTimeout = setTimeout(() => {
this._renderSessionTabsImmediate();
}, 100);
}
_renderSessionTabsImmediate() {
const container = document.getElementById('sessionTabs');
// Build tabs HTML
@@ -528,9 +552,17 @@ class ClaudemanApp {
try {
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
const data = await res.json();
this.terminal.clear();
this.terminal.reset();
if (data.terminalBuffer) {
this.terminal.write(data.terminalBuffer);
// Strip leading ANSI escape sequences and whitespace to prevent gaps
// This handles:
// - CSI sequences: ESC [ (params) (final) - includes ? for private modes
// - OSC sequences: ESC ] ... BEL or ESC \
// - Simple sequences: ESC followed by single char
// - Whitespace, CR, LF
let cleanBuffer = data.terminalBuffer;
cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[DEMNOP78>=c]|\s|\r|\n)*/g, '');
this.terminal.write(cleanBuffer);
}
// Send resize
@@ -1583,6 +1615,54 @@ class ClaudemanApp {
}, 3000);
}
// ========== System Stats ==========
startSystemStatsPolling() {
// Initial fetch
this.fetchSystemStats();
// Poll every 2 seconds
this.systemStatsInterval = setInterval(() => {
this.fetchSystemStats();
}, 2000);
}
async fetchSystemStats() {
try {
const res = await fetch('/api/system/stats');
const stats = await res.json();
this.updateSystemStatsDisplay(stats);
} catch (err) {
// Silently fail - system stats are not critical
}
}
updateSystemStatsDisplay(stats) {
const cpuEl = document.getElementById('statCpu');
const memEl = document.getElementById('statMem');
if (cpuEl) {
cpuEl.textContent = `CPU: ${stats.cpu}%`;
// Color based on usage
if (stats.cpu > 80) {
cpuEl.classList.add('high');
} else {
cpuEl.classList.remove('high');
}
}
if (memEl) {
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
memEl.textContent = `Mem: ${memGB}GB`;
// Color based on usage
if (stats.memory.percent > 80) {
memEl.classList.add('high');
} else {
memEl.classList.remove('high');
}
}
}
// ========== Utility ==========
escapeHtml(text) {
+4
View File
@@ -35,6 +35,10 @@
<span class="status-text">Connecting</span>
</div>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
<span class="stat-cpu" id="statCpu">CPU: --</span>
<span class="stat-mem" id="statMem">Mem: --</span>
</div>
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">&#x2699;</button>
</div>
</header>
+20
View File
@@ -187,6 +187,26 @@ body {
font-family: 'SF Mono', Monaco, monospace;
}
.header-system-stats {
display: flex;
gap: 0.75rem;
font-size: 0.7rem;
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-dim);
padding: 0 0.5rem;
border-left: 1px solid var(--border);
margin-left: 0.5rem;
}
.header-system-stats span {
white-space: nowrap;
}
.header-system-stats .high {
color: var(--red);
font-weight: 500;
}
.btn-icon-header {
display: flex;
align-items: center;
+165 -5
View File
@@ -3,7 +3,7 @@ import fastifyStatic from '@fastify/static';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
@@ -44,6 +44,14 @@ interface ScheduledRun {
// Batch terminal data for performance - collect for 16ms (60fps) before sending
const TERMINAL_BATCH_INTERVAL = 16;
// Batch session:output events for 50ms
const OUTPUT_BATCH_INTERVAL = 50;
// Batch task:updated events for 100ms
const TASK_UPDATE_BATCH_INTERVAL = 100;
// Scheduled runs cleanup interval (check every 5 minutes)
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
// Completed scheduled runs max age (1 hour)
const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
export class WebServer extends EventEmitter {
private app: FastifyInstance;
@@ -58,6 +66,13 @@ export class WebServer extends EventEmitter {
// Terminal batching for performance
private terminalBatches: Map<string, string> = new Map();
private terminalBatchTimer: NodeJS.Timeout | null = null;
// Scheduled runs cleanup timer
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
// SSE event batching
private outputBatches: Map<string, string> = new Map();
private outputBatchTimer: NodeJS.Timeout | null = null;
private taskUpdateBatches: Map<string, BackgroundTask> = new Map();
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
constructor(port: number = 3000) {
super();
@@ -751,11 +766,46 @@ export class WebServer extends EventEmitter {
this.screenManager.stopStatsCollection();
return { success: true };
});
// System stats endpoint for frontend header display
this.app.get('/api/system/stats', async () => {
return this.getSystemStats();
});
}
// Get system CPU and memory usage
private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number } } {
try {
// Memory stats
const totalMem = totalmem();
const freeMem = freemem();
const usedMem = totalMem - freeMem;
// CPU load average (1 min) as percentage (rough approximation)
const load = loadavg()[0];
const cpuCount = cpus().length;
const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100));
return {
cpu: cpuPercent,
memory: {
usedMB: Math.round(usedMem / (1024 * 1024)),
totalMB: Math.round(totalMem / (1024 * 1024)),
percent: Math.round((usedMem / totalMem) * 100)
}
};
} catch {
return {
cpu: 0,
memory: { usedMB: 0, totalMB: 0, percent: 0 }
};
}
}
private setupSessionListeners(session: Session): void {
session.on('output', (data) => {
this.broadcast('session:output', { id: session.id, data });
// Use batching for better performance at high throughput
this.batchOutputData(session.id, data);
});
session.on('terminal', (data) => {
@@ -804,7 +854,8 @@ export class WebServer extends EventEmitter {
});
session.on('taskUpdated', (task: BackgroundTask) => {
this.broadcast('task:updated', { sessionId: session.id, task });
// Use batching for better performance at high update rates
this.batchTaskUpdate(session.id, task);
});
session.on('taskCompleted', (task: BackgroundTask) => {
@@ -929,9 +980,10 @@ export class WebServer extends EventEmitter {
};
while (Date.now() < run.endAt && run.status === 'running') {
let session: Session | null = null;
try {
// Create a session for this iteration
const session = new Session({ workingDir: run.workingDir });
session = new Session({ workingDir: run.workingDir });
this.sessions.set(session.id, session);
this.setupSessionListeners(session);
run.sessionId = session.id;
@@ -950,11 +1002,30 @@ export class WebServer extends EventEmitter {
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
this.broadcast('scheduled:updated', run);
// Clean up the session after iteration to prevent memory leaks
await session.stop();
this.sessions.delete(session.id);
this.terminalBatches.delete(session.id);
run.sessionId = null;
// Small pause between iterations
await new Promise(r => setTimeout(r, 2000));
} catch (err) {
addLog(`Error: ${(err as Error).message}`);
this.broadcast('scheduled:updated', run);
// Clean up the session on error too
if (session) {
try {
await session.stop();
this.sessions.delete(session.id);
this.terminalBatches.delete(session.id);
} catch {
// Ignore cleanup errors
}
run.sessionId = null;
}
// Continue despite errors
await new Promise(r => setTimeout(r, 5000));
}
@@ -989,6 +1060,31 @@ export class WebServer extends EventEmitter {
return Array.from(this.sessions.values()).map(s => s.toDetailedState());
}
// Clean up old completed scheduled runs
private cleanupScheduledRuns(): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [id, run] of this.scheduledRuns) {
// Only clean up completed, failed, or stopped runs
if (run.status !== 'running') {
const age = now - (run.endAt || run.startedAt);
if (age > SCHEDULED_RUN_MAX_AGE) {
toDelete.push(id);
}
}
}
for (const id of toDelete) {
this.scheduledRuns.delete(id);
this.broadcast('scheduled:deleted', { id });
}
if (toDelete.length > 0) {
console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`);
}
}
private getFullState() {
// Build respawn status map
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
@@ -1041,11 +1137,57 @@ export class WebServer extends EventEmitter {
this.terminalBatches.clear();
}
// Batch session:output events at 50ms for better performance
private batchOutputData(sessionId: string, data: string): void {
const existing = this.outputBatches.get(sessionId) || '';
this.outputBatches.set(sessionId, existing + data);
if (!this.outputBatchTimer) {
this.outputBatchTimer = setTimeout(() => {
this.flushOutputBatches();
this.outputBatchTimer = null;
}, OUTPUT_BATCH_INTERVAL);
}
}
private flushOutputBatches(): void {
for (const [sessionId, data] of this.outputBatches) {
if (data.length > 0) {
this.broadcast('session:output', { id: sessionId, data });
}
}
this.outputBatches.clear();
}
// Batch task:updated events at 100ms - only send latest update per session
private batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
this.taskUpdateBatches.set(sessionId, task);
if (!this.taskUpdateBatchTimer) {
this.taskUpdateBatchTimer = setTimeout(() => {
this.flushTaskUpdateBatches();
this.taskUpdateBatchTimer = null;
}, TASK_UPDATE_BATCH_INTERVAL);
}
}
private flushTaskUpdateBatches(): void {
for (const [sessionId, task] of this.taskUpdateBatches) {
this.broadcast('task:updated', { sessionId, task });
}
this.taskUpdateBatches.clear();
}
async start(): Promise<void> {
await this.setupRoutes();
await this.app.listen({ port: this.port, host: '0.0.0.0' });
console.log(`Claudeman web interface running at http://localhost:${this.port}`);
// Start scheduled runs cleanup timer
this.scheduledCleanupTimer = setInterval(() => {
this.cleanupScheduledRuns();
}, SCHEDULED_CLEANUP_INTERVAL);
// Restore screen sessions from previous run
await this.restoreScreenSessions();
}
@@ -1093,13 +1235,31 @@ export class WebServer extends EventEmitter {
}
async stop(): Promise<void> {
// Clear batch timer
// Clear batch timers
if (this.terminalBatchTimer) {
clearTimeout(this.terminalBatchTimer);
this.terminalBatchTimer = null;
}
this.terminalBatches.clear();
if (this.outputBatchTimer) {
clearTimeout(this.outputBatchTimer);
this.outputBatchTimer = null;
}
this.outputBatches.clear();
if (this.taskUpdateBatchTimer) {
clearTimeout(this.taskUpdateBatchTimer);
this.taskUpdateBatchTimer = null;
}
this.taskUpdateBatches.clear();
// Clear scheduled cleanup timer
if (this.scheduledCleanupTimer) {
clearInterval(this.scheduledCleanupTimer);
this.scheduledCleanupTimer = null;
}
// Stop screen stats collection
this.screenManager.stopStatsCollection();