Files
Codeman/docs/performance-investigation-report.md
T
arkonandClaude Opus 4.6 3795c45cc1 chore: rename Claudeman to Codeman
Full product rename across 109 files (~834 occurrences):
- Env vars: CLAUDEMAN_* → CODEMAN_*
- Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/
- tmux prefix: claudeman- → codeman-
- localStorage: claudeman-* → codeman-*
- Package/CLI: claudeman → codeman
- GitHub repo: Ark0N/Claudeman → Ark0N/Codeman
- systemd service: claudeman-web → codeman-web
- Class: ClaudemanApp → CodemanApp

Migration infrastructure for seamless transition:
- state-store.ts: auto-migrates data directories on startup
- tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions)
- app.js: localStorage key migration (preserves old keys)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:44:34 +01:00

15 KiB

Codeman Performance Investigation Report

Date: 2026-02-20 Scope: Why Codeman feels sluggish when multiple Claude tabs are very busy Method: 4-agent parallel analysis of server, PTY pipeline, frontend, and background systems


Executive Summary

When multiple Claude sessions are actively producing heavy terminal output (e.g., building, writing files, running tests), Codeman's UI becomes sluggish. This investigation identified 14 bottlenecks across 4 layers of the stack. The root cause is cumulative event loop blocking — no single operation is catastrophically slow, but dozens of small synchronous operations run on every PTY data chunk, and with N busy sessions producing chunks every few milliseconds, the event loop gets saturated.

The most impactful findings are ranked by severity below.


Critical Findings (Event Loop Blockers)

1. PTY Data Handler Chain — O(output_volume) per session, synchronous

File: src/session.ts:986-1086 Severity: CRITICAL

Every chunk of PTY output from a busy Claude session runs through this synchronous chain on the Node.js event loop:

PTY onData → ANSI strip regex → ralph-tracker → bash-tool-parser →
             token parser → CLI info parser → task description parser →
             idle/working detection → emit('terminal') → emit('output')

Key costs per chunk:

  • ANSI_ESCAPE_PATTERN_FULL regex (line 999): Complex regex with alternation, runs on every chunk where any consumer needs clean data
  • ralphTracker.processCleanData() (line 1014): Splits into lines, runs regex per line, checks multi-line patterns
  • bashToolParser.processCleanData() (line 1020): Similar line-by-line regex processing
  • parseTaskDescriptionsFromTerminalData() (line 1038): Regex scan for parenthesized descriptions
  • Working/idle detection (lines 1043-1085): Multiple includes() checks plus getCleanData() calls

The lazy getCleanData() pattern (line 997-1002) was a good optimization — it avoids ANSI stripping when no consumer needs it. But when Ralph tracking is enabled (common during active work), getCleanData() is called on every chunk, negating the optimization.

With 5 busy sessions producing 50+ chunks/second each, this means 250+ synchronous processing chains per second on the event loop. Each chain involves string allocation, regex matching, and line splitting.

2. Broadcast Serialization — JSON.stringify on every flush

File: src/web/server.ts:4941-4967 Severity: CRITICAL

The broadcast() method calls JSON.stringify(data) synchronously for every event. Terminal data is the highest-frequency event. During flushTerminalBatches() (line 5030), broadcast is called once per session with pending data. With 10 busy sessions flushing every 16-50ms, that's 200-625 JSON.stringify calls per second on terminal data alone.

The terminal data payload is a string that gets double-encoded: the raw terminal string is embedded inside a JSON object {id, data}, then that object is JSON.stringify'd. For large chunks (up to 32KB per the BATCH_FLUSH_THRESHOLD), this creates significant garbage collection pressure.

Additionally, the session:updated broadcast includes toLightDetailedState() which serializes taskTree, tokens, bufferStats, and respawnConfig — this is called on many state changes, not just terminal data.

3. Single-Timer Batching — All sessions share one setTimeout

File: src/web/server.ts:5017-5027 Severity: HIGH

The batchTerminalData() method uses a single shared timer (this.terminalBatchTimer) for all sessions. When the timer fires, flushTerminalBatches() iterates ALL pending sessions and broadcasts each one. This means:

  • One extremely busy session's rapid data can force the timer to fire at the minimum interval (16ms), flushing ALL sessions at that rate
  • The flush itself iterates all pending sessions synchronously
  • The _minBatchInterval optimization (line 5003) means the fastest session dictates the timer for everyone

This creates a thundering herd effect: all session flushes happen in a single synchronous burst rather than being staggered.

4. State Persistence Storms

File: src/web/server.ts:3879-3917 Severity: HIGH

persistSessionState() is called from 28+ locations in server.ts. Each call sets a 100ms debounce timer per session. During heavy activity, this means:

  • Frequent timer creation/cancellation (GC pressure)
  • The actual persist (_persistSessionStateNow) calls session.toState() which creates a new object, then store.setSession() which triggers JSON.stringify of the entire state store and writeFileSync to disk

The StateStore (via state-store.ts) debounces its own write, but the overhead is in the per-session toState() serialization and object creation, not just the disk write.


High-Severity Findings

5. Ralph Tracker Line Processing — O(lines) per chunk

File: src/ralph-tracker.ts:1337-1375 Severity: HIGH (when Ralph tracking is enabled)

When enabled, processCleanData():

  1. Appends to a line buffer (string concatenation)
  2. Splits on \n (creates array)
  3. Calls processLine() on each line (regex matching per line)
  4. Calls checkMultiLinePatterns() (additional regex on full chunk)
  5. Calls maybeCleanupExpiredTodos() (iterates todos Map)

For a busy session producing 100+ lines/second, this is significant. The line buffer can grow up to MAX_LINE_BUFFER_SIZE before being truncated, and the split/iterate pattern creates garbage on every chunk.

6. Subagent Watcher Polling — O(agents) every 1-10 seconds

File: src/subagent-watcher.ts:225-274 Severity: MEDIUM-HIGH

Three periodic operations:

  • Poll interval (1s): Lightweight check, but full directory scan every 5th poll (5s)
  • Liveness check (10s): Runs pgrep (child process spawn), then iterates ALL tracked agents to check if alive. With 50+ subagents (common with agent teams), this is a non-trivial burst.
  • File watchers: One chokidar watcher per tracked agent directory, plus transcript file watchers. With many agents, this means many active file watchers consuming kernel inotify resources.

The getClaudePids() call spawns a child process (pgrep) every 10 seconds. Under heavy load, child process spawning competes with the event loop.

7. SSE Client Iteration — O(clients) per broadcast

File: src/web/server.ts:4964-4966 Severity: MEDIUM

Every broadcast() iterates all SSE clients to send the pre-formatted message. With multiple browser tabs or mobile clients, each flush sends data to every client. The reply.raw.write() call goes through Node's HTTP stream, which is generally non-blocking but can cause backpressure cascades.

The backpressure handling (line 4916-4938) correctly skips backpressured clients, but the once('drain') handler sends a session:needsRefresh event, which the client responds to by fetching the full buffer — potentially a 2MB request — amplifying the problem.

8. Event Emitter Fan-Out in Session

File: src/session.ts:1008-1009 Severity: MEDIUM

Every PTY data chunk emits TWO events: terminal and output. The terminal event triggers batchTerminalData() in server.ts. The output event may trigger additional handlers. EventEmitter dispatch is synchronous — all listeners run before the next operation in the PTY handler continues.

With busy sessions, this means every chunk blocks the event loop for: PTY processing + all terminal listeners + all output listeners.


Medium-Severity Findings

9. Respawn Controller Timer Accumulation

File: src/respawn-controller.ts (various) Severity: MEDIUM

Each session with respawn enabled runs multiple timers:

  • Idle detection timeout
  • AI checker interval (when active)
  • Output silence detection interval
  • Token stability interval
  • Circuit breaker state timeouts

With 10 sessions with respawn, that's 50+ active timers. While individual timers are cheap, the cumulative effect on the event loop's timer queue is non-trivial — the libuv timer heap has O(log n) insertion but all callbacks run synchronously.

10. Team Watcher Polling

File: src/team-watcher.ts Severity: MEDIUM (when agent teams are active)

Polls ~/.claude/teams/ directory every few seconds. Each poll reads config.json files and task files. With active teams, this adds filesystem reads to the event loop's I/O budget.

11. Frontend Terminal Write Batching

File: src/web/public/app.js (batchTerminalWrite/flushPendingWrites) Severity: MEDIUM

The frontend batches terminal writes at requestAnimationFrame rate (16ms). When receiving SSE events from multiple busy sessions:

  • batchTerminalWrite() is called for EVERY session's data, even sessions not currently displayed
  • Terminal instances exist for all sessions (not just the active tab)
  • Each flushPendingWrites() calls terminal.write() which triggers xterm.js rendering

Hidden tabs still process terminal writes, consuming CPU for rendering that's never displayed.

12. Frontend Connection Line Rendering

File: src/web/public/app.js (updateConnectionLines) Severity: LOW-MEDIUM

Connection lines between parent/child agent windows are recalculated on window moves, resizes, and potentially on terminal writes. With many subagent windows open, this involves DOM reads (getBoundingClientRect) that force layout recalculation.

13. Image Watcher File System Events

File: src/image-watcher.ts Severity: LOW

Uses chokidar to watch for image files in session working directories. With many sessions in the same or overlapping directories, watchers may generate redundant events. The awaitWriteFinish and burst throttling mitigate this, but the kernel inotify resources add up.

14. ANSI Escape Regex Complexity

File: src/session.ts:999 Severity: LOW (but cumulative)

ANSI_ESCAPE_PATTERN_FULL is a complex regex with multiple alternation branches. While V8's regex engine handles this well for typical terminal data, adversarial input (deeply nested escape sequences) could cause superlinear matching time. The FOCUS_ESCAPE_FILTER regex runs first on every chunk.


Scaling Analysis

Resource Per Session 10 Sessions 20 Sessions
PTY data handlers 1 synchronous chain 10 chains competing for event loop 20 chains — event loop saturation likely
Broadcast calls (terminal only) 20-60/sec 200-600/sec 400-1200/sec
JSON.stringify (terminal) 20-60/sec 200-600/sec 400-1200/sec
Active timers ~5 ~50 ~100
File watchers (subagents) 2-5 20-50 40-100
SSE writes per flush N clients N clients x 10 sessions N clients x 20 sessions
Ralph line processing O(lines/sec) O(10 x lines/sec) O(20 x lines/sec)

The critical threshold appears to be 5-8 simultaneously busy sessions, where the cumulative PTY processing + broadcast serialization + timer callbacks start to exceed the event loop's capacity for responsive handling.


Root Cause Architecture Diagram

 Busy Claude Session 1 ─┐
 Busy Claude Session 2 ─┤    ┌──────────────────────┐
 Busy Claude Session 3 ─┼───→│  Node.js Event Loop   │
 Busy Claude Session 4 ─┤    │  (SINGLE THREAD)      │
 Busy Claude Session 5 ─┘    │                        │
                              │  PTY handlers (sync)   │◄── BOTTLENECK 1
                              │  ANSI strip regex      │
                              │  Ralph tracker         │
                              │  Bash tool parser      │
                              │  Idle detection        │
                              │         │              │
                              │         ▼              │
                              │  EventEmitter.emit()   │◄── BOTTLENECK 2
                              │         │              │
                              │         ▼              │
                              │  batchTerminalData()   │
                              │  (shared timer)        │◄── BOTTLENECK 3
                              │         │              │
                              │         ▼              │
                              │  flushTerminalBatches() │
                              │  broadcast() per session│
                              │  JSON.stringify() each  │◄── BOTTLENECK 4
                              │  write() to N clients   │
                              │                        │
                              │  + persistSessionState  │◄── BOTTLENECK 5
                              │  + respawn timers       │
                              │  + subagent polling     │
                              │  + team watcher         │
                              └────────────────────────┘

Recommendations (Not Implemented — For Discussion)

Tier 1: Highest Impact, Lowest Risk

  1. Disable processing for non-visible sessions: Skip Ralph tracking, bash tool parsing, and task description parsing for sessions that no active SSE client is viewing. Only buffer terminal data.
  2. Per-session flush staggering: Instead of one shared timer flushing all sessions, use individual timers offset by index * (interval/N) to spread flushes across the batch window.
  3. Skip hidden tab terminal writes on frontend: Don't call terminal.write() for terminals not in the active tab. Lazy-load on tab switch.

Tier 2: Medium Impact

  1. Worker thread for ANSI stripping and parsing: Move the regex-heavy ANSI strip + Ralph parsing to a worker thread pool. PTY data → worker → clean data back to main thread.
  2. Pre-formatted SSE messages for terminal data: Since terminal events are just {id, data}, build the SSE message string directly without JSON.stringify.
  3. Adaptive processing based on load: When event loop lag exceeds a threshold (measured via setTimeout(0) drift), reduce processing — skip Ralph, increase batch intervals, reduce subagent poll frequency.

Tier 3: Longer-Term Architectural

  1. Process-per-session or cluster mode: Move each session's PTY handling to a separate Node.js worker or process, communicating to the main server via IPC.
  2. Binary protocol for terminal data: Replace JSON-encoded SSE terminal events with binary frames (e.g., MessagePack or raw binary WebSocket frames) to eliminate double-encoding.
  3. Selective SSE subscriptions: Clients subscribe to specific sessions instead of receiving all events. The server only broadcasts to interested clients.

How to Validate

To confirm these findings, instrument with:

// Add to event loop — measures how long synchronous work takes
let lastCheck = Date.now();
setInterval(() => {
  const now = Date.now();
  const lag = now - lastCheck - 100; // 100ms interval
  if (lag > 10) console.log(`[PERF] Event loop lag: ${lag}ms`);
  lastCheck = now;
}, 100);

And in flushTerminalBatches():

const start = performance.now();
// ... existing flush logic ...
const elapsed = performance.now() - start;
if (elapsed > 5) console.log(`[PERF] Flush took ${elapsed.toFixed(1)}ms for ${this.terminalBatches.size} sessions`);

This will show exactly when and how much the event loop is being blocked during heavy session activity.