diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 1d604248..dbb779a0 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -64,6 +64,57 @@ const WHITESPACE_PATTERN = /\s+/g; */ const READY_INDICATOR = '↵ send'; +// ========== Buffer Accumulator ========== + +/** + * High-performance buffer accumulator using array-based collection. + * Reduces GC pressure by avoiding repeated string concatenation. + */ +class BufferAccumulator { + private chunks: string[] = []; + private totalLength: number = 0; + private readonly maxSize: number; + private readonly trimSize: number; + + constructor(maxSize: number, trimSize: number) { + this.maxSize = maxSize; + this.trimSize = trimSize; + } + + append(data: string): void { + if (!data) return; + this.chunks.push(data); + this.totalLength += data.length; + if (this.totalLength > this.maxSize) { + this.trim(); + } + } + + get value(): string { + if (this.chunks.length === 0) return ''; + if (this.chunks.length === 1) return this.chunks[0]; + const result = this.chunks.join(''); + this.chunks = [result]; + return result; + } + + get length(): number { + return this.totalLength; + } + + clear(): void { + this.chunks = []; + this.totalLength = 0; + } + + private trim(): void { + const full = this.chunks.join(''); + const trimmed = full.slice(-this.trimSize); + this.chunks = [trimmed]; + this.totalLength = trimmed.length; + } +} + // ========== Type Definitions ========== /** @@ -264,8 +315,8 @@ export class RespawnController extends EventEmitter { /** Timestamp of last terminal activity */ private lastActivityTime: number = 0; - /** Buffer for recent terminal output */ - private terminalBuffer: string = ''; + /** Buffer for recent terminal output (uses BufferAccumulator to reduce GC pressure) */ + private terminalBuffer = new BufferAccumulator(MAX_RESPAWN_BUFFER_SIZE, RESPAWN_BUFFER_TRIM_SIZE); /** Whether a prompt indicator was detected */ private promptDetected: boolean = false; @@ -466,12 +517,8 @@ export class RespawnController extends EventEmitter { * @param data - Raw terminal output data */ private handleTerminalData(data: string): void { - this.terminalBuffer += data; - - // Keep buffer manageable (max 1MB, trim to 512KB) - if (this.terminalBuffer.length > MAX_RESPAWN_BUFFER_SIZE) { - this.terminalBuffer = this.terminalBuffer.slice(-RESPAWN_BUFFER_TRIM_SIZE); - } + // BufferAccumulator handles auto-trimming when max size exceeded + this.terminalBuffer.append(data); // Check for the definitive "ready for input" indicator const isReady = data.includes(READY_INDICATOR); @@ -606,7 +653,7 @@ export class RespawnController extends EventEmitter { */ private startMonitoringInit(): void { this.setState('monitoring_init'); - this.terminalBuffer = ''; + this.terminalBuffer.clear(); this.workingDetected = false; this.log('Monitoring if /init triggered work...'); @@ -641,7 +688,7 @@ export class RespawnController extends EventEmitter { */ private sendKickstart(): void { this.setState('sending_kickstart'); - this.terminalBuffer = ''; + this.terminalBuffer.clear(); this.stepTimer = setTimeout(() => { const prompt = this.config.kickstartPrompt!; @@ -723,7 +770,7 @@ export class RespawnController extends EventEmitter { */ private sendUpdateDocs(): void { this.setState('sending_update'); - this.terminalBuffer = ''; // Clear buffer for fresh detection + this.terminalBuffer.clear(); // Clear buffer for fresh detection this.stepTimer = setTimeout(() => { const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI @@ -742,7 +789,7 @@ export class RespawnController extends EventEmitter { */ private sendClear(): void { this.setState('sending_clear'); - this.terminalBuffer = ''; + this.terminalBuffer.clear(); this.stepTimer = setTimeout(() => { this.log('Sending /clear'); @@ -759,7 +806,7 @@ export class RespawnController extends EventEmitter { */ private sendInit(): void { this.setState('sending_init'); - this.terminalBuffer = ''; + this.terminalBuffer.clear(); this.stepTimer = setTimeout(() => { this.log('Sending /init'); @@ -782,7 +829,7 @@ export class RespawnController extends EventEmitter { // Go back to watching state for next cycle this.setState('watching'); - this.terminalBuffer = ''; + this.terminalBuffer.clear(); this.promptDetected = false; this.workingDetected = false; } diff --git a/src/session.ts b/src/session.ts index f3ccf4c8..2e2f5fc3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -60,6 +60,77 @@ const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; +// ============================================================================ +// Buffer Accumulator (reduces GC pressure from string concatenation) +// ============================================================================ + +/** + * High-performance buffer accumulator using array-based collection. + * + * Reduces GC pressure by avoiding repeated string concatenation (`+=`). + * Instead, chunks are pushed to an array and joined only when needed. + * Automatically trims when size limits are exceeded. + */ +class BufferAccumulator { + private chunks: string[] = []; + private totalLength: number = 0; + private readonly maxSize: number; + private readonly trimSize: number; + + constructor(maxSize: number, trimSize: number) { + this.maxSize = maxSize; + this.trimSize = trimSize; + } + + /** Append data to the buffer */ + append(data: string): void { + if (!data) return; + this.chunks.push(data); + this.totalLength += data.length; + + // Trim if exceeded max size + if (this.totalLength > this.maxSize) { + this.trim(); + } + } + + /** Get the full buffer content (joins all chunks) */ + get value(): string { + if (this.chunks.length === 0) return ''; + if (this.chunks.length === 1) return this.chunks[0]; + + // Consolidate chunks on access + const result = this.chunks.join(''); + this.chunks = [result]; + return result; + } + + /** Get current buffer length without joining */ + get length(): number { + return this.totalLength; + } + + /** Clear the buffer */ + clear(): void { + this.chunks = []; + this.totalLength = 0; + } + + /** Set buffer to a specific value */ + set(value: string): void { + this.chunks = value ? [value] : []; + this.totalLength = value?.length || 0; + } + + /** Trim buffer to keep only the most recent data */ + private trim(): void { + const full = this.chunks.join(''); + const trimmed = full.slice(-this.trimSize); + this.chunks = [trimmed]; + this.totalLength = trimmed.length; + } +} + /** * Represents a JSON message from Claude CLI's stream-json output format. * Messages are newline-delimited JSON objects parsed from PTY output. @@ -177,9 +248,10 @@ export class Session extends EventEmitter { private _pid: number | null = null; private _status: SessionStatus = 'idle'; private _currentTaskId: string | null = null; - private _terminalBuffer: string = ''; // Raw terminal output + // Use BufferAccumulator for hot-path buffers to reduce GC pressure + private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE); private _outputBuffer: string = ''; - private _textOutput: string = ''; + private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE); private _errorBuffer: string = ''; private _lastActivityAt: number; private _claudeSessionId: string | null = null; @@ -261,7 +333,7 @@ export class Session extends EventEmitter { } get terminalBuffer(): string { - return this._terminalBuffer; + return this._terminalBuffer.value; } get outputBuffer(): string { @@ -269,7 +341,7 @@ export class Session extends EventEmitter { } get textOutput(): string { - return this._textOutput; + return this._textOutput.value; } get errorBuffer(): string { @@ -422,8 +494,8 @@ export class Session extends EventEmitter { mode: this.mode, claudeSessionId: this._claudeSessionId, totalCost: this._totalCost, - textOutput: this._textOutput, - terminalBuffer: this._terminalBuffer, + textOutput: this._textOutput.value, + terminalBuffer: this._terminalBuffer.value, messageCount: this._messages.length, isWorking: this._isWorking, lastPromptTime: this._lastPromptTime, @@ -479,9 +551,9 @@ export class Session extends EventEmitter { } this._status = 'busy'; - this._terminalBuffer = ''; + this._terminalBuffer.clear(); this._outputBuffer = ''; - this._textOutput = ''; + this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._lineBuffer = ''; @@ -521,13 +593,15 @@ export class Session extends EventEmitter { if (!isRestoredSession) { const checkForPrompt = setInterval(() => { // Wait for the prompt character (❯) which means Claude is fully initialized - if (this._terminalBuffer.includes('❯') || this._terminalBuffer.includes('\u276f')) { + const bufferValue = this._terminalBuffer.value; + if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) { clearInterval(checkForPrompt); // Clean the buffer - remove screen init junk before actual content // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace - this._terminalBuffer = this._terminalBuffer - .replace(/^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/, ''); + this._terminalBuffer.set( + bufferValue.replace(/^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/, '') + ); // Signal client to refresh this.emit('clearTerminal'); } @@ -571,14 +645,10 @@ export class Session extends EventEmitter { .replace(/\x0c/g, ''); // Remove Ctrl+L if (!data) return; // Skip if only filtered sequences - this._terminalBuffer += data; + // BufferAccumulator handles auto-trimming when max size exceeded + this._terminalBuffer.append(data); this._lastActivityAt = Date.now(); - // Trim buffer if it exceeds max size to prevent memory issues - if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) { - this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE); - } - this.emit('terminal', data); this.emit('output', data); @@ -650,9 +720,9 @@ export class Session extends EventEmitter { } this._status = 'busy'; - this._terminalBuffer = ''; + this._terminalBuffer.clear(); this._outputBuffer = ''; - this._textOutput = ''; + this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._lineBuffer = ''; @@ -694,7 +764,7 @@ export class Session extends EventEmitter { if (!isRestoredSession) { setTimeout(() => { if (this.ptyProcess) { - this._terminalBuffer = ''; + this._terminalBuffer.clear(); this.ptyProcess.write('clear\n'); } }, 100); @@ -730,14 +800,10 @@ export class Session extends EventEmitter { const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); if (!data) return; // Skip if only focus sequences - this._terminalBuffer += data; + // BufferAccumulator handles auto-trimming when max size exceeded + this._terminalBuffer.append(data); this._lastActivityAt = Date.now(); - // Trim buffer if it exceeds max size - if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) { - this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE); - } - this.emit('terminal', data); this.emit('output', data); }); @@ -789,9 +855,9 @@ export class Session extends EventEmitter { } this._status = 'busy'; - this._terminalBuffer = ''; + this._terminalBuffer.clear(); this._outputBuffer = ''; - this._textOutput = ''; + this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._lineBuffer = ''; @@ -832,14 +898,10 @@ export class Session extends EventEmitter { const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); if (!data) return; // Skip if only focus sequences - this._terminalBuffer += data; + // BufferAccumulator handles auto-trimming when max size exceeded + this._terminalBuffer.append(data); this._lastActivityAt = Date.now(); - // Trim buffer if it exceeds max size to prevent memory issues - if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) { - this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE); - } - this.emit('terminal', data); this.emit('output', data); @@ -867,12 +929,12 @@ export class Session extends EventEmitter { } else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) { this._status = 'error'; if (this.rejectPromise) { - this.rejectPromise(new Error(this._errorBuffer || this._textOutput || 'Process exited with error')); + this.rejectPromise(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error')); } } else { this._status = 'idle'; if (this.resolvePromise) { - this.resolvePromise({ result: this._textOutput || this._terminalBuffer, cost: this._totalCost }); + this.resolvePromise({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost }); } } @@ -895,7 +957,7 @@ export class Session extends EventEmitter { // Prevent unbounded line buffer growth for very long lines if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { // Force flush the oversized buffer as text output - this._textOutput += this._lineBuffer + '\n'; + this._textOutput.append(this._lineBuffer + '\n'); this._lineBuffer = ''; } @@ -905,7 +967,7 @@ export class Session extends EventEmitter { this._lineBufferFlushTimer = null; if (this._lineBuffer.length > 0) { // Flush partial line as text output - this._textOutput += this._lineBuffer; + this._textOutput.append(this._lineBuffer); this._lineBuffer = ''; } }, LINE_BUFFER_FLUSH_INTERVAL); @@ -946,7 +1008,7 @@ export class Session extends EventEmitter { if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text' && block.text) { - this._textOutput += block.text; + this._textOutput.append(block.text); } } // Track tokens from usage @@ -965,17 +1027,13 @@ export class Session extends EventEmitter { } } catch { // Not JSON, just regular output - this._textOutput += line + '\n'; + this._textOutput.append(line + '\n'); } } else if (trimmed) { - this._textOutput += line + '\n'; + this._textOutput.append(line + '\n'); } } - - // Trim text output buffer for long-running sessions - if (this._textOutput.length > MAX_TEXT_OUTPUT_SIZE) { - this._textOutput = this._textOutput.slice(-TEXT_OUTPUT_TRIM_SIZE); - } + // Note: BufferAccumulator auto-trims when max size exceeded } // Parse token count from Claude's status line in interactive mode @@ -1274,7 +1332,7 @@ export class Session extends EventEmitter { } getOutput(): string { - return this._textOutput; + return this._textOutput.value; } getError(): string { @@ -1282,13 +1340,13 @@ export class Session extends EventEmitter { } getTerminalBuffer(): string { - return this._terminalBuffer; + return this._terminalBuffer.value; } clearBuffers(): void { - this._terminalBuffer = ''; + this._terminalBuffer.clear(); this._outputBuffer = ''; - this._textOutput = ''; + this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._taskTracker.clear();