From 11ddcfaf906a7d1bfbf3abbd241f1a00cefae114 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 13 Dec 2025 01:21:52 -0500 Subject: [PATCH] fix: throttle terminal output to prevent system lockup under heavy load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch terminal output at ~60fps max to prevent overwhelming WebSocket - Reduce scrollback buffer from 100KB to 50KB per terminal - Clean up flush timeouts on session kill/cleanup - Should fix lockups when running npm run dev with high output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/services/terminal-service.ts | 50 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 17fc5d49..b05d987c 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -11,7 +11,11 @@ import * as os from "os"; import * as fs from "fs"; // Maximum scrollback buffer size (characters) -const MAX_SCROLLBACK_SIZE = 100000; // ~100KB per terminal +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal + +// Throttle output to prevent overwhelming WebSocket under heavy load +const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate +const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch export interface TerminalSession { id: string; @@ -20,6 +24,8 @@ export interface TerminalSession { createdAt: Date; shell: string; scrollbackBuffer: string; // Store recent output for replay on reconnect + outputBuffer: string; // Pending output to be flushed + flushTimeout: NodeJS.Timeout | null; // Throttle timer } export interface TerminalOptions { @@ -205,11 +211,33 @@ export class TerminalService extends EventEmitter { createdAt: new Date(), shell, scrollbackBuffer: "", + outputBuffer: "", + flushTimeout: null, }; this.sessions.set(id, session); - // Forward data events and store in scrollback buffer + // Flush buffered output to clients (throttled) + const flushOutput = () => { + if (session.outputBuffer.length === 0) return; + + // Send in batches if buffer is large + let dataToSend = session.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } else { + session.outputBuffer = ""; + session.flushTimeout = null; + } + + this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); + this.emit("data", id, dataToSend); + }; + + // Forward data events with throttling ptyProcess.onData((data) => { // Append to scrollback buffer session.scrollbackBuffer += data; @@ -218,8 +246,13 @@ export class TerminalService extends EventEmitter { session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); } - this.dataCallbacks.forEach((cb) => cb(id, data)); - this.emit("data", id, data); + // Buffer output for throttled delivery + session.outputBuffer += data; + + // Schedule flush if not already scheduled + if (!session.flushTimeout) { + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } }); // Handle exit @@ -274,6 +307,11 @@ export class TerminalService extends EventEmitter { return false; } try { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } session.pty.kill(); this.sessions.delete(sessionId); console.log(`[Terminal] Session ${sessionId} killed`); @@ -339,6 +377,10 @@ export class TerminalService extends EventEmitter { console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`); this.sessions.forEach((session, id) => { try { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + } session.pty.kill(); } catch { // Ignore errors during cleanup