Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 83 additions & 22 deletions apps/desktop/src/main/lib/terminal-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;

constructor(
workspaceId: string,
Expand Down Expand Up @@ -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);
Comment on lines +256 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 bytesWritten over-counted when chunks are dropped under backpressure

bytesWritten is unconditionally incremented in write() before data reaches the debounce buffer:

this.bytesWritten += bytes;      // ← always runs
this.debounceBuffer.push(data);

But in flushDebounceBuffer(), when the in-memory backlog is full (pendingWriteBytes + bytes > MAX_PENDING_WRITE_BYTES), the batch is silently dropped with no rollback of bytesWritten. This inflates the counter, causing the MAX_HISTORY_BYTES cap check in write() to fire prematurely — terminal history gets truncated well before the 5 MB limit when disk I/O is slow.

Before this PR, bytesWritten was only incremented on paths that actually wrote or queued data. The refactor moved the increment before the drop gate in flushDebounceBuffer(), creating a regression.

Fix: remove this.bytesWritten += bytes from write() and add it in flushDebounceBuffer() only on the two paths that actually persist data:

// queue path (backpressured):
this.pendingWrites.push({ data: combined, bytes });
this.pendingWriteBytes += bytes;
this.bytesWritten += bytes;   // only count what's queued
return;

// direct write path:
const ok = this.stream.write(combined, "utf8");
this.bytesWritten += bytes;   // only count what's written

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;
}

Comment on lines +289 to +303

@cubic-dev-ai cubic-dev-ai Bot Apr 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the backlog cap is hit, the buffered chunk is dropped but bytesWritten is never rolled back, so history output can be suppressed early even though the data was never written. Adjust bytesWritten when dropping the debounce buffer under backpressure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/terminal-history.ts, line 296:

<comment>When the backlog cap is hit, the buffered chunk is dropped but bytesWritten is never rolled back, so history output can be suppressed early even though the data was never written. Adjust bytesWritten when dropping the debounce buffer under backpressure.</comment>

<file context>
@@ -249,28 +259,57 @@ export class HistoryWriter {
+
+		// Respect filesystem backpressure.
+		if (this.isBackpressured || this.pendingWrites.length > 0) {
+			if (this.pendingWriteBytes + bytes > MAX_PENDING_WRITE_BYTES) {
+				if (!this.warnedBackpressureDrop) {
+					this.warnedBackpressureDrop = true;
</file context>
Suggested change
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;
}
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`,
);
}
this.bytesWritten = Math.max(0, this.bytesWritten - bytes);
return;
}
this.pendingWrites.push({ data: combined, bytes });
this.pendingWriteBytes += bytes;
return;
}
Fix with Cubic

try {
const ok = this.stream.write(combined, "utf8");
if (!ok) {
this.isBackpressured = true;
}
Expand Down Expand Up @@ -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<void>((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();
}
});
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
24 changes: 22 additions & 2 deletions apps/desktop/src/main/lib/terminal/port-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ 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;

// 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.
Expand Down Expand Up @@ -64,6 +67,8 @@ class PortManager extends EventEmitter {
private scanInterval: ReturnType<typeof setInterval> | null = null;
private pendingHintScans = new Map<string, ReturnType<typeof setTimeout>>();
private isScanning = false;
/** Tracks the last time a port-hint was seen for each pane, used to skip idle sessions */
private lastHintActivity = new Map<string, number>();

constructor() {
super();
Expand All @@ -84,6 +89,7 @@ class PortManager extends EventEmitter {
this.sessions.delete(paneId);
this.removePortsForPane(paneId);
this.clearPendingHintScan(paneId);
this.lastHintActivity.delete(paneId);
}

/**
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;

@cubic-dev-ai cubic-dev-ai Bot Apr 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Sessions that never emit a port hint are now treated as idle immediately, so the periodic scan will never run for them. That breaks the safety-net sweep and can prevent port detection for servers that don’t print a hint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/terminal/port-manager.ts, line 249:

<comment>Sessions that never emit a port hint are now treated as idle immediately, so the periodic scan will never run for them. That breaks the safety-net sweep and can prevent port detection for servers that don’t print a hint.</comment>

<file context>
@@ -220,10 +233,28 @@ class PortManager extends EventEmitter {
+		}
+
+		const lastHint = this.lastHintActivity.get(paneId);
+		if (lastHint === undefined) return true;
+		return Date.now() - lastHint > IDLE_SESSION_TIMEOUT_MS;
+	}
</file context>
Suggested change
if (lastHint === undefined) return true;
if (lastHint === undefined) {
this.lastHintActivity.set(paneId, Date.now());
return false;
}
Fix with Cubic

return Date.now() - lastHint > IDLE_SESSION_TIMEOUT_MS;
}

private async collectRegularSessionPids(scanState: ScanState): Promise<void> {
const tasks: Promise<void>[] = [];
for (const [paneId, { session, workspaceId }] of this.sessions) {
if (!session.isAlive) continue;
if (this.isSessionIdle(paneId)) continue;
tasks.push(
this.collectPidTree({
paneId,
Expand All @@ -240,6 +259,7 @@ class PortManager extends EventEmitter {
const tasks: Promise<void>[] = [];
for (const [paneId, { workspaceId, pid }] of this.daemonSessions) {
if (pid === null) continue;
if (this.isSessionIdle(paneId)) continue;
tasks.push(
this.collectPidTree({
paneId,
Expand Down
Loading