Skip to content
Merged
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
3 changes: 1 addition & 2 deletions src/config/buffer-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
* it easy to tune memory usage.
*
* Memory Budget Rationale (for 20 concurrent sessions):
* - Terminal buffer: 2MB max × 20 = 40MB worst case
* - Terminal buffer: 32MB max × 20 = 640MB worst case
* - Text output: 1MB max × 20 = 20MB worst case
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
* - Total buffer overhead: ~80MB (acceptable for a long-running server)
*
* @module config/buffer-limits
*/
Expand Down
30 changes: 21 additions & 9 deletions src/config/terminal-history.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
/**
* Defaults, bounds, and resolution for terminal history retention.
*
* Centralizes the terminal scrollback, tmux history-limit, and server PTY buffer
* byte caps that were previously scattered as hardcoded literals. Each value is
* overridable (env var or the settings object) and clamped to a sane range via
* resolveTerminalHistoryConfig(). Defaults intentionally match the prior
* hardcoded values, so introducing this module is behavior-neutral.
* Raised defaults (the ones actually wired):
* - tmux history-limit: 50,000 -> 100,000 lines (applied at session spawn)
* - server PTY buffer cap: 2MB max / 1.5MB trim -> 32MB / 24MB (via buffer-limits.ts)
* Browser xterm scrollback is a separate hardcoded DEFAULT_SCROLLBACK (50,000) in
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
* All values remain env- and settings-overridable and bounds-clamped via
* resolveTerminalHistoryConfig().
*/

export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000;
export const DEFAULT_TMUX_HISTORY_LIMIT = 50_000;
export const DEFAULT_TMUX_HISTORY_LIMIT = 100_000;
export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES =
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 2 * 1024 * 1024;
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES =
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 1.5 * 1024 * 1024;
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 32 * 1024 * 1024;
// Trim must stay below the max: BufferAccumulator.trim() keeps the last trimSize chars, so a
// trim >= max never shrinks the buffer — every append then re-joins the whole string (O(n²))
// and memory overshoots the operator's cap (e.g. CODEMAN_MAX_TERMINAL_BUFFER=2097152 with no
// trim env would leave the 24MB trim default in force). Clamp to 75% of the resolved max,
// preserving the 24MB/32MB default ratio as trim hysteresis.
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES = Math.min(
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 24 * 1024 * 1024,
Math.floor(DEFAULT_TERMINAL_BUFFER_MAX_BYTES * 0.75)
);

export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
Expand Down
3 changes: 2 additions & 1 deletion src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/* Already set globally as fallback */
}),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js.
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
// in constants.js), which stays lower to protect browser/mobile memory.
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
})
Expand Down
39 changes: 31 additions & 8 deletions test/terminal-history.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
resolveTerminalHistoryConfig,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
Expand All @@ -22,11 +22,34 @@ describe('resolveTerminalHistoryConfig', () => {
});
});

it('defaults match the prior hardcoded values (behavior-neutral)', () => {
expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(50_000);
it('defaults raise tmux history to 100k and buffer caps to 32MB/24MB; browser scrollback stays 50k', () => {
expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(100_000);
// Matches the hardcoded browser-side DEFAULT_SCROLLBACK in src/web/public/constants.js —
// deliberately NOT raised (100k xterm lines per tab is a mobile-memory hazard).
expect(DEFAULT_TERMINAL_SCROLLBACK_LINES).toBe(50_000);
expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024);
expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(1.5 * 1024 * 1024);
expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(32 * 1024 * 1024);
expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(24 * 1024 * 1024);
});

it('clamps the env-derived trim default below an env-lowered max (CODEMAN_MAX_TERMINAL_BUFFER only)', async () => {
const originalMax = process.env.CODEMAN_MAX_TERMINAL_BUFFER;
const originalTrim = process.env.CODEMAN_TRIM_TERMINAL_TO;
vi.resetModules();
process.env.CODEMAN_MAX_TERMINAL_BUFFER = String(2 * 1024 * 1024);
delete process.env.CODEMAN_TRIM_TERMINAL_TO;
try {
const mod = await import('../src/config/terminal-history.js');
expect(mod.DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024);
// trim >= max would make BufferAccumulator.trim() a no-op (unbounded growth + O(n²) appends).
expect(mod.DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBeLessThan(mod.DEFAULT_TERMINAL_BUFFER_MAX_BYTES);
expect(mod.DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(Math.floor(2 * 1024 * 1024 * 0.75));
} finally {
if (originalMax === undefined) delete process.env.CODEMAN_MAX_TERMINAL_BUFFER;
else process.env.CODEMAN_MAX_TERMINAL_BUFFER = originalMax;
if (originalTrim === undefined) delete process.env.CODEMAN_TRIM_TERMINAL_TO;
else process.env.CODEMAN_TRIM_TERMINAL_TO = originalTrim;
vi.resetModules();
}
});

it('passes valid in-range values through unchanged', () => {
Expand Down Expand Up @@ -80,9 +103,9 @@ describe('resolveTerminalHistoryConfig', () => {
});

it('caps the default trim to a lowered max', () => {
// Default trim (1.5MB) exceeds a 1MB max → trim must fall to the max.
const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 * 1024 * 1024 });
expect(cfg.terminalBufferTrimBytes).toBe(1 * 1024 * 1024);
// Default trim (24MB) exceeds a 2MB max → trim must fall to the max.
const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 2 * 1024 * 1024 });
expect(cfg.terminalBufferTrimBytes).toBe(2 * 1024 * 1024);
});

it('truncates fractional inputs to integers', () => {
Expand Down