diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 77e2e8700c2..5487154a438 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -22,6 +22,7 @@ import { SUPERSET_DIR_NAME } from "shared/constants"; const MAX_HISTORY_BYTES = 5 * 1024 * 1024; // 5MB per session const MAX_PENDING_WRITE_BYTES = 256 * 1024; // cap in-memory backlog when disk is slow const DRAIN_TIMEOUT_MS = 1000; +export const WRITE_DEBOUNCE_MS = 250; // debounce disk writes to reduce filesystem events const HISTORY_DIR_MODE = 0o700; const HISTORY_FILE_MODE = 0o600; @@ -135,6 +136,9 @@ export class HistoryWriter { private pendingWriteBytes = 0; private warnedCapReached = false; private warnedBackpressureDrop = false; + private debounceBuffer: string[] = []; + private debounceBufferBytes = 0; + private debounceTimer: ReturnType | null = null; constructor( workspaceId: string, @@ -249,28 +253,56 @@ export class HistoryWriter { return; } - // Respect filesystem backpressure. When disk is slow, stop feeding the - // stream buffer and keep a small in-memory backlog; beyond that we drop. - if (this.isBackpressured || this.pendingWrites.length > 0) { - if (this.pendingWriteBytes + bytes > MAX_PENDING_WRITE_BYTES) { - if (!this.warnedBackpressureDrop) { - this.warnedBackpressureDrop = true; - console.warn( - `[HistoryWriter] Write backlog cap reached for ${this.paneId} (${MAX_PENDING_WRITE_BYTES} bytes); dropping history until drain`, - ); - } - return; - } + this.bytesWritten += bytes; + this.debounceBuffer.push(data); + this.debounceBufferBytes += bytes; + + if (!this.debounceTimer) { + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + this.flushDebounceBuffer(); + }, WRITE_DEBOUNCE_MS); + this.debounceTimer.unref(); + } + } catch { + this.streamErrored = true; + } + } - this.pendingWrites.push({ data, bytes }); - this.pendingWriteBytes += bytes; - this.bytesWritten += bytes; + private flushDebounceBuffer(): void { + if ( + this.closed || + this.streamErrored || + !this.stream || + this.debounceBuffer.length === 0 + ) { + return; + } + + const combined = this.debounceBuffer.join(""); + const bytes = this.debounceBufferBytes; + this.debounceBuffer = []; + this.debounceBufferBytes = 0; + + // Respect filesystem backpressure. + if (this.isBackpressured || this.pendingWrites.length > 0) { + if (this.pendingWriteBytes + bytes > MAX_PENDING_WRITE_BYTES) { + if (!this.warnedBackpressureDrop) { + this.warnedBackpressureDrop = true; + console.warn( + `[HistoryWriter] Write backlog cap reached for ${this.paneId} (${MAX_PENDING_WRITE_BYTES} bytes); dropping history until drain`, + ); + } return; } - // node-pty produces UTF-8 strings - this.bytesWritten += bytes; - const ok = this.stream.write(data, "utf8"); + this.pendingWrites.push({ data: combined, bytes }); + this.pendingWriteBytes += bytes; + return; + } + + try { + const ok = this.stream.write(combined, "utf8"); if (!ok) { this.isBackpressured = true; } @@ -317,12 +349,22 @@ export class HistoryWriter { return; } + // Flush debounce buffer first + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.flushDebounceBuffer(); + this.flushPendingWrites(); + + // drain only fires after stream.write() returned false + if (!this.isBackpressured) { + return; + } + return new Promise((resolve) => { - this.flushPendingWrites(); - // Cork and uncork forces a flush this.stream?.once("drain", resolve); - // If nothing to drain, resolve immediately - if (this.stream?.writableLength === 0) { + if (!this.isBackpressured) { resolve(); } }); @@ -335,6 +377,13 @@ export class HistoryWriter { if (this.closed) { return; } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.flushDebounceBuffer(); + this.closed = true; // Best-effort: flush any pending backlog before closing. @@ -408,6 +457,12 @@ export class HistoryWriter { this.bytesWritten = 0; this.warnedCapReached = false; this.warnedBackpressureDrop = false; + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.debounceBuffer = []; + this.debounceBufferBytes = 0; // Reset metadata with new start time this.metadata.startedAt = new Date().toISOString(); @@ -433,6 +488,12 @@ export class HistoryWriter { this.stream = null; this.pendingWrites = []; this.pendingWriteBytes = 0; + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.debounceBuffer = []; + this.debounceBufferBytes = 0; this.closed = true; // Delete the directory diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index 4b7fdaf8e7b..f83df4677cf 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -8,8 +8,8 @@ import { } from "./port-scanner"; import type { TerminalSession } from "./types"; -// How often to poll for port changes (in ms) -const SCAN_INTERVAL_MS = 2500; +// safety-net sweep; hint scans handle dev-server starts +export const SCAN_INTERVAL_MS = 10_000; // Delay before scanning after a port hint is detected (in ms) const HINT_SCAN_DELAY_MS = 500; @@ -17,6 +17,9 @@ const HINT_SCAN_DELAY_MS = 500; // Ports to ignore (common system ports that are usually not dev servers) const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); +// skipped by periodic scan; hint scans still fire immediately +export const IDLE_SESSION_TIMEOUT_MS = 60_000; + /** * Check if terminal output contains hints that a port may have been opened. * Common patterns from dev servers, test frameworks, etc. @@ -64,6 +67,8 @@ class PortManager extends EventEmitter { private scanInterval: ReturnType | null = null; private pendingHintScans = new Map>(); private isScanning = false; + /** Tracks the last time a port-hint was seen for each pane, used to skip idle sessions */ + private lastHintActivity = new Map(); constructor() { super(); @@ -84,6 +89,7 @@ class PortManager extends EventEmitter { this.sessions.delete(paneId); this.removePortsForPane(paneId); this.clearPendingHintScan(paneId); + this.lastHintActivity.delete(paneId); } /** @@ -106,10 +112,12 @@ class PortManager extends EventEmitter { this.daemonSessions.delete(paneId); this.removePortsForPane(paneId); this.clearPendingHintScan(paneId); + this.lastHintActivity.delete(paneId); } checkOutputForHint(data: string, paneId: string): void { if (!containsPortHint(data)) return; + this.lastHintActivity.set(paneId, Date.now()); this.scheduleHintScan(paneId); } @@ -220,10 +228,21 @@ class PortManager extends EventEmitter { }; } + private isSessionIdle(paneId: string): boolean { + for (const port of this.ports.values()) { + if (port.paneId === paneId) return false; + } + + const lastHint = this.lastHintActivity.get(paneId); + if (lastHint === undefined) return true; + return Date.now() - lastHint > IDLE_SESSION_TIMEOUT_MS; + } + private async collectRegularSessionPids(scanState: ScanState): Promise { const tasks: Promise[] = []; for (const [paneId, { session, workspaceId }] of this.sessions) { if (!session.isAlive) continue; + if (this.isSessionIdle(paneId)) continue; tasks.push( this.collectPidTree({ paneId, @@ -240,6 +259,7 @@ class PortManager extends EventEmitter { const tasks: Promise[] = []; for (const [paneId, { workspaceId, pid }] of this.daemonSessions) { if (pid === null) continue; + if (this.isSessionIdle(paneId)) continue; tasks.push( this.collectPidTree({ paneId,