fix: prevent orphaned claude processes and session memory leaks

- killScreen now finds and kills all child processes before quitting screen
  to prevent claude processes from becoming orphaned (the main bug)
- Add cleanupSession() method for comprehensive resource cleanup:
  respawn controllers, timers, batches, event listeners, and sessions
- Fix /api/run endpoint to cleanup sessions after completion
- Fix /api/quick-start error path to cleanup on failure
- Add MAX_CONCURRENT_SESSIONS (50) limit to prevent unbounded growth
- Update CLAUDE.md with improved documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 11:19:13 +01:00
co-authored by Claude Opus 4.5
parent 23cafd2556
commit a77abb574b
3 changed files with 159 additions and 68 deletions
+37 -34
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
**Tech Stack**: TypeScript (ES2022/NodeNext), Node.js, Fastify, Server-Sent Events, node-pty **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
**Requirements**: Node.js 18+, Claude CLI (`claude`) installed and available in PATH **Requirements**: Node.js 18+, Claude CLI (`claude`) installed and available in PATH
@@ -22,35 +22,42 @@ npx tsx src/index.ts web -p 8080 # Dev mode with custom port
node dist/index.js web # After npm run build node dist/index.js web # After npm run build
claudeman web # After npm link claudeman web # After npm link
# IMPORTANT: `npm run dev` runs the CLI help, NOT the web server # GOTCHA: `npm run dev` runs CLI help, NOT the web server
# Always use `npx tsx src/index.ts web` for development # Always use `npx tsx src/index.ts web` for development
# Testing (vitest) # Testing (vitest with vi.mock() - no real Claude CLI spawned)
npm run test # Run all tests once npm run test # Run all tests once
npm run test:watch # Watch mode npm run test:watch # Watch mode
npm run test:coverage # With coverage report npm run test:coverage # With coverage report
npx vitest run test/session.test.ts # Single file npx vitest run test/session.test.ts # Single file
npx vitest run -t "should create session" # By pattern npx vitest run -t "should create session" # By pattern
# Debugging
screen -ls # List GNU screen sessions
screen -r <name> # Attach to screen session
curl localhost:3000/api/sessions # Check active sessions
``` ```
## Architecture ## Architecture
``` ```
src/ src/
├── index.ts # CLI entry point (commander) ├── index.ts # CLI entry (commander)
├── cli.ts # CLI command implementations ├── cli.ts # CLI commands
├── session.ts # Core: PTY wrapper for Claude CLI + token tracking ├── session.ts # Core: PTY wrapper for Claude CLI + token tracking
├── session-manager.ts # Manages multiple sessions ├── session-manager.ts # Manages multiple sessions
├── screen-manager.ts # GNU screen session persistence + process stats ├── screen-manager.ts # GNU screen persistence + process stats
├── respawn-controller.ts # Auto-respawn state machine ├── respawn-controller.ts # Auto-respawn state machine
├── task-tracker.ts # Background task detection and tree display
├── ralph-loop.ts # Autonomous task assignment ├── ralph-loop.ts # Autonomous task assignment
├── task.ts / task-queue.ts # Priority queue with dependencies ├── task-queue.ts # Priority queue with dependencies
├── state-store.ts # Persistence to ~/.claudeman/state.json ├── state-store.ts # Persistence to ~/.claudeman/state.json
├── types.ts # All TypeScript interfaces ├── types.ts # All TypeScript interfaces
├── web/ ├── web/
│ ├── server.ts # Fastify REST API + SSE + session restoration │ ├── server.ts # Fastify REST API + SSE + session restoration
│ └── public/ # Static frontend files (vanilla JS, xterm.js) │ └── public/ # Vanilla JS frontend (xterm.js, no bundler)
│ ├── app.js # Main app logic, SSE handling, tab management
│ ├── styles.css # All styles including responsive/mobile
│ └── index.html # Single page with modal templates
└── templates/ └── templates/
└── claude-md.ts # CLAUDE.md generator for new cases └── claude-md.ts # CLAUDE.md generator for new cases
``` ```
@@ -64,18 +71,18 @@ src/
### Key Components ### Key Components
- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal` events. - **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot, `startInteractive()` for persistent terminal. Emits events: `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal`.
- **RespawnController** (`src/respawn-controller.ts`): State machine that keeps interactive sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → repeats. - **RespawnController** (`src/respawn-controller.ts`): State machine that keeps sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → repeats. State flow: `WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING`
- **ScreenManager** (`src/screen-manager.ts`): Manages GNU screen sessions for persistent terminals. Screens survive server restarts. - **ScreenManager** (`src/screen-manager.ts`): Wraps sessions in GNU screen for persistence across server restarts. On startup, reconciles with `screen -ls` to restore sessions.
- **WebServer** (`src/web/server.ts`): Fastify server with REST API + SSE. All endpoints under `/api/`. See file for full route list. - **WebServer** (`src/web/server.ts`): Fastify server with REST API (`/api/*`) + SSE (`/api/events`). Wires session events to SSE broadcast.
### Session Modes ### Session Modes
- **One-Shot** (`runPrompt(prompt)`): Execute single prompt, receive completion event, session exits - **One-Shot** (`runPrompt(prompt)`): Single prompt execution, emits completion, exits
- **Interactive** (`startInteractive()`): Persistent PTY terminal with resize support, buffer persistence - **Interactive** (`startInteractive()`): Persistent PTY terminal with resize support
- **Shell** (`startShell()`): Plain bash/zsh terminal without Claude - **Shell** (`startShell()`): Plain bash/zsh terminal without Claude
## Code Patterns ## Code Patterns
@@ -104,38 +111,35 @@ pty.spawn('claude', ['--dangerously-skip-permissions'], { ... })
### Idle Detection ### Idle Detection
Session detects idle by watching for prompt character (`❯` or `\u276f`) and waiting 2 seconds without activity. RespawnController uses the same patterns plus spinner characters to detect working state. Session detects idle by watching for prompt character (`❯` or `\u276f`) and waiting 2 seconds without activity.
### Token Tracking ### Token Tracking
- **One-shot mode**: Uses `--output-format stream-json` for detailed token usage from JSON - **One-shot mode**: Uses `--output-format stream-json` for detailed token usage from JSON
- **Interactive mode**: Parses tokens from Claude's status line (e.g., "123.4k tokens"), estimates 60/40 input/output split - **Interactive mode**: Parses tokens from Claude's status line (e.g., "123.4k tokens"), estimates 60/40 input/output split
### Respawn Controller State Machine ### Terminal Display Fix (Tab Switch & New Session)
``` When switching tabs or creating new sessions, terminal may be rendered at wrong size. Fix sequence:
WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING
```
### Screen Session Initialization
GNU screen creates blank space at top when initializing. After attaching:
- **Claude sessions**: Clear buffer, emit `clearTerminal` event, client clears xterm
- **Shell sessions**: Clear buffer, send `clear\n` command
### Tab Switching Fix
When switching Claude session tabs, terminal may be rendered at wrong size. Fix sequence:
1. Clear and reset xterm 1. Clear and reset xterm
2. Write terminal buffer 2. Write terminal buffer
3. Send resize to update PTY dimensions 3. Send resize to update PTY dimensions
4. Send Ctrl+L (`\x0c`) to trigger Claude CLI redraw 4. Send Ctrl+L (`\x0c`) to trigger Claude CLI redraw
Uses `pendingCtrlL` Set to track sessions needing the fix. Waits for `session:idle` or `session:working` SSE event before sending resize + Ctrl+L.
### SSE Events ### SSE Events
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`. All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events: `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `respawn:stateChanged`. Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events: `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`.
### Frontend (app.js)
The frontend uses vanilla JS with xterm.js. Key patterns:
- **SSE handling**: `handleSSEEvent()` switch statement dispatches all event types
- **Tab management**: `switchToSession()` handles terminal buffer restore + resize
- **60fps rendering**: Server batches at 16ms intervals, client uses `requestAnimationFrame`
## Adding New Features ## Adding New Features
@@ -158,7 +162,6 @@ Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`.
- State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json` - State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json`
- Cases created in `~/claudeman-cases/` by default - Cases created in `~/claudeman-cases/` by default
- Sessions wrapped in GNU screen for persistence across server restarts - Kill All works on restored sessions: `Session.stop()` checks screenManager directly by session ID
- Tests use vitest with `vi.mock()` - no real Claude CLI spawned - Long-running sessions (12-24+ hours) supported with automatic buffer trimming (5MB terminal, 2MB text, 1000 messages max)
- Long-running sessions (12-24+ hours) supported with automatic buffer trimming
- E2E testing available via agent-browser (see `.claude/skills/e2e-test.md`) - E2E testing available via agent-browser (see `.claude/skills/e2e-test.md`)
+51 -1
View File
@@ -115,13 +115,61 @@ export class ScreenManager extends EventEmitter {
} }
} }
// Kill a screen session // Get all child process PIDs recursively
private getChildPids(pid: number): number[] {
const pids: number[] = [];
try {
const output = execSync(`pgrep -P ${pid}`, {
encoding: 'utf-8',
timeout: 5000
}).trim();
if (output) {
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p))) {
pids.push(childPid);
// Recursively get grandchildren
pids.push(...this.getChildPids(childPid));
}
}
} catch {
// No children or command failed
}
return pids;
}
// Kill a screen session and all its child processes
async killScreen(sessionId: string): Promise<boolean> { async killScreen(sessionId: string): Promise<boolean> {
const screen = this.screens.get(sessionId); const screen = this.screens.get(sessionId);
if (!screen) { if (!screen) {
return false; return false;
} }
// First, find and kill ALL child processes of the screen session
// This prevents orphaned claude processes when screen quits
const childPids = this.getChildPids(screen.pid);
console.log(`[ScreenManager] Killing screen ${screen.screenName} (PID ${screen.pid}) and ${childPids.length} child processes`);
// Kill children in reverse order (deepest first) with SIGTERM then SIGKILL
for (const childPid of childPids.reverse()) {
try {
process.kill(childPid, 'SIGTERM');
} catch {
// Process may already be dead
}
}
// Give processes a moment to terminate gracefully
await new Promise(resolve => setTimeout(resolve, 200));
// Force kill any remaining children
for (const childPid of childPids) {
try {
process.kill(childPid, 'SIGKILL');
} catch {
// Process already terminated
}
}
// Now kill the screen session itself
try { try {
// Kill screen session by name // Kill screen session by name
execSync(`screen -S ${screen.screenName} -X quit`, { execSync(`screen -S ${screen.screenName} -X quit`, {
@@ -131,6 +179,8 @@ export class ScreenManager extends EventEmitter {
// Try killing by PID if name-based kill failed // Try killing by PID if name-based kill failed
try { try {
process.kill(screen.pid, 'SIGTERM'); process.kill(screen.pid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, 100));
process.kill(screen.pid, 'SIGKILL');
} catch { } catch {
// Already dead // Already dead
} }
+71 -33
View File
@@ -52,6 +52,8 @@ const TASK_UPDATE_BATCH_INTERVAL = 100;
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
// Completed scheduled runs max age (1 hour) // Completed scheduled runs max age (1 hour)
const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
// Maximum concurrent sessions to prevent resource exhaustion
const MAX_CONCURRENT_SESSIONS = 50;
export class WebServer extends EventEmitter { export class WebServer extends EventEmitter {
private app: FastifyInstance; private app: FastifyInstance;
@@ -128,6 +130,11 @@ export class WebServer extends EventEmitter {
this.app.get('/api/sessions', async () => this.getSessionsState()); this.app.get('/api/sessions', async () => this.getSessionsState());
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => { this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
// Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.` };
}
const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string }; const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string };
const workingDir = body.workingDir || process.cwd(); const workingDir = body.workingDir || process.cwd();
const session = new Session({ const session = new Session({
@@ -166,23 +173,12 @@ export class WebServer extends EventEmitter {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const query = req.query as { killScreen?: string }; const query = req.query as { killScreen?: string };
const killScreen = query.killScreen !== 'false'; // Default to true const killScreen = query.killScreen !== 'false'; // Default to true
const session = this.sessions.get(id);
if (!session) { if (!this.sessions.has(id)) {
return { success: false, error: 'Session not found' }; return { success: false, error: 'Session not found' };
} }
// Stop respawn controller first await this.cleanupSession(id, killScreen);
const controller = this.respawnControllers.get(id);
if (controller) {
controller.stop();
this.respawnControllers.delete(id);
}
await session.stop(killScreen);
this.sessions.delete(id);
this.terminalBatches.delete(id);
this.broadcast('session:deleted', { id });
return { success: true }; return { success: true };
}); });
@@ -192,19 +188,8 @@ export class WebServer extends EventEmitter {
let killed = 0; let killed = 0;
for (const id of sessionIds) { for (const id of sessionIds) {
const session = this.sessions.get(id); if (this.sessions.has(id)) {
if (session) { await this.cleanupSession(id);
// Stop respawn controller first
const controller = this.respawnControllers.get(id);
if (controller) {
controller.stop();
this.respawnControllers.delete(id);
}
await session.stop();
this.sessions.delete(id);
this.terminalBatches.delete(id);
this.broadcast('session:deleted', { id });
killed++; killed++;
} }
} }
@@ -530,8 +515,13 @@ export class WebServer extends EventEmitter {
}; };
}); });
// Quick run (create session, run prompt, return result) // Quick run (create session, run prompt, return result, then cleanup)
this.app.post('/api/run', async (req) => { this.app.post('/api/run', async (req) => {
// Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
}
const { prompt, workingDir } = req.body as QuickRunRequest; const { prompt, workingDir } = req.body as QuickRunRequest;
const dir = workingDir || process.cwd(); const dir = workingDir || process.cwd();
@@ -543,8 +533,12 @@ export class WebServer extends EventEmitter {
try { try {
const result = await session.runPrompt(prompt); const result = await session.runPrompt(prompt);
// Clean up session after completion to prevent memory leak
await this.cleanupSession(session.id);
return { success: true, sessionId: session.id, ...result }; return { success: true, sessionId: session.id, ...result };
} catch (err) { } catch (err) {
// Clean up session on error too
await this.cleanupSession(session.id);
return { success: false, sessionId: session.id, error: (err as Error).message }; return { success: false, sessionId: session.id, error: (err as Error).message };
} }
}); });
@@ -648,6 +642,11 @@ export class WebServer extends EventEmitter {
// Quick Start: Create case (if needed) and start interactive session in one click // Quick Start: Create case (if needed) and start interactive session in one click
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => { this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
// Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
}
const { caseName = 'testcase' } = req.body as QuickStartRequest; const { caseName = 'testcase' } = req.body as QuickStartRequest;
// Validate case name // Validate case name
@@ -697,6 +696,8 @@ export class WebServer extends EventEmitter {
caseName, caseName,
}; };
} catch (err) { } catch (err) {
// Clean up session on error to prevent orphaned resources
await this.cleanupSession(session.id);
return { success: false, error: (err as Error).message }; return { success: false, error: (err as Error).message };
} }
}); });
@@ -802,6 +803,40 @@ export class WebServer extends EventEmitter {
} }
} }
// Clean up all resources associated with a session
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
const session = this.sessions.get(sessionId);
// Stop and remove respawn controller
const controller = this.respawnControllers.get(sessionId);
if (controller) {
controller.stop();
controller.removeAllListeners();
this.respawnControllers.delete(sessionId);
}
// Clear respawn timer
const timerInfo = this.respawnTimers.get(sessionId);
if (timerInfo) {
clearTimeout(timerInfo.timer);
this.respawnTimers.delete(sessionId);
}
// Clear batches
this.terminalBatches.delete(sessionId);
this.outputBatches.delete(sessionId);
this.taskUpdateBatches.delete(sessionId);
// Stop session and remove listeners
if (session) {
session.removeAllListeners();
await session.stop(killScreen);
this.sessions.delete(sessionId);
}
this.broadcast('session:deleted', { id: sessionId });
}
private setupSessionListeners(session: Session): void { private setupSessionListeners(session: Session): void {
session.on('output', (data) => { session.on('output', (data) => {
// Use batching for better performance at high throughput // Use batching for better performance at high throughput
@@ -994,6 +1029,13 @@ export class WebServer extends EventEmitter {
}; };
while (Date.now() < run.endAt && run.status === 'running') { while (Date.now() < run.endAt && run.status === 'running') {
// Check session limit before creating new session
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
addLog(`Waiting: maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
await new Promise(r => setTimeout(r, 5000));
continue;
}
let session: Session | null = null; let session: Session | null = null;
try { try {
// Create a session for this iteration // Create a session for this iteration
@@ -1017,9 +1059,7 @@ export class WebServer extends EventEmitter {
this.broadcast('scheduled:updated', run); this.broadcast('scheduled:updated', run);
// Clean up the session after iteration to prevent memory leaks // Clean up the session after iteration to prevent memory leaks
await session.stop(); await this.cleanupSession(session.id);
this.sessions.delete(session.id);
this.terminalBatches.delete(session.id);
run.sessionId = null; run.sessionId = null;
// Small pause between iterations // Small pause between iterations
@@ -1031,9 +1071,7 @@ export class WebServer extends EventEmitter {
// Clean up the session on error too // Clean up the session on error too
if (session) { if (session) {
try { try {
await session.stop(); await this.cleanupSession(session.id);
this.sessions.delete(session.id);
this.terminalBatches.delete(session.id);
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }