Merge pull request #331 from dignfei/fix/shell-history-performance

fix(terminal): bound shell history replay
This commit is contained in:
Ark0N
2026-08-21 20:23:17 +02:00
committed by GitHub
16 changed files with 414 additions and 138 deletions
+6 -4
View File
@@ -32,7 +32,7 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
const rewriteDecision = source.indexOf(
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer',
selectStart
);
@@ -58,7 +58,9 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
// Snapshot restore must NOT short-circuit the canonical fetch.
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
expect(postSnapshotRestore).toContain('restoredSnapshot');
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
expect(postSnapshotRestore).toContain(
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer'
);
});
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
@@ -71,8 +73,8 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
expect(cacheRestore).toBeGreaterThan(-1);
expect(busyClear).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(busyClear);
expect(replayBlock).toContain('clearedForBusy');
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
expect(replayBlock).toContain('clearedBeforeFresh');
expect(replayBlock).toContain('restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer');
});
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
+12
View File
@@ -122,6 +122,18 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
expect(app).not.toContain('earlier output truncated for performance');
});
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)');
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
expect(app).toContain("if (session?.mode === 'shell')");
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
});
it('renders the banner through textContent, never innerHTML', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('_renderHistoryTruncationBanner() {');
+1
View File
@@ -789,6 +789,7 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toContain(lastLine);
expect(body.data.source).toBe('mux-full-history');
expect(typeof body.data.fullSize).toBe('number');
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
});
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
+80 -3
View File
@@ -89,7 +89,7 @@ describe('terminal flush budget', () => {
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
let resolved = false;
@@ -109,13 +109,90 @@ describe('terminal flush budget', () => {
expect(writes).toEqual(['fresh tmux pane frame']);
expect(writeDone).toBeTypeOf('function');
expect(resolved).toBe(false);
expect(finishBufferLoad).not.toHaveBeenCalled();
expect(finishBufferLoad).toHaveBeenCalledOnce();
writeDone?.();
await promise;
expect(resolved).toBe(true);
expect(finishBufferLoad).toHaveBeenCalledOnce();
});
it('paces a large enqueue and releases live output before the parse marker completes', async () => {
const { app, writes } = loadTerminalUiHarness('shell');
const scheduled: Array<() => void> = [];
let parseDone: (() => void) | undefined;
let resolved = false;
let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined;
app._safeYield = (callback: () => void) => scheduled.push(callback);
app.isTerminalAtBottom = () => true;
app.terminal.buffer = { active: { length: 37 } };
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
if (callback) parseDone = callback;
};
const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => {
result = value;
resolved = true;
});
expect(writes).toEqual([]);
expect(scheduled).toHaveLength(1);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024]);
expect(scheduled).toHaveLength(1);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]);
expect(resolved).toBe(false);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]);
expect(app._isLoadingBuffer).toBe(false);
expect(resolved).toBe(false);
app.batchTerminalWrite('new output after snapshot');
expect(app._loadBufferQueue).toBe(null);
expect(app.pendingWrites).toEqual(['new output after snapshot']);
parseDone?.();
app.terminal.buffer.active.length = 42;
await promise;
expect(resolved).toBe(true);
expect(result?.bufferLength).toBe(37);
expect(result?.completed).toBe(true);
});
it('marks a parse callback stale when a newer replay supersedes it', async () => {
const { app } = loadTerminalUiHarness('shell');
let parseDone: (() => void) | undefined;
app.terminal.write = (_data: string, callback?: () => void) => {
parseDone = callback;
};
const firstReplay = app.chunkedTerminalWrite('old snapshot');
app._chunkedWriteGen += 1;
parseDone?.();
await expect(firstReplay).resolves.toMatchObject({ completed: false });
});
it('scans xterm rows from buffer.length instead of double-counting baseY', () => {
const { app } = loadTerminalUiHarness('shell');
const getLine = vi.fn((index: number) =>
index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined
);
app.terminal = {
rows: 24,
buffer: { active: { baseY: 76, length: 100, getLine } },
scrollToBottom: vi.fn(),
scrollToLine: vi.fn(),
};
app.scrollToLastNonEmptyLine();
expect(getLine.mock.calls[0]?.[0]).toBe(99);
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77);
});
it('keeps stale buffer load owners from finishing a newer load', () => {
+57 -1
View File
@@ -675,6 +675,7 @@ describe('TmuxManager (unit)', () => {
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
workingDir: '/mnt/gdrive/project with spaces',
mode: 'shell',
historyLimit: 250_000,
});
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
@@ -683,7 +684,9 @@ describe('TmuxManager (unit)', () => {
const newSessionCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
);
expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`);
expect(newSessionCall?.[0]).toBe(
`tmux -L 'codeman' set-option -g history-limit 250000 \\; new-session -ds "codeman-abc12345" -c /tmp \\; set-option -t "codeman-abc12345" history-limit 250000`
);
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
const respawnCall = mockedExecSync.mock.calls.find(
@@ -696,6 +699,59 @@ describe('TmuxManager (unit)', () => {
}
});
it('changes the global history default on tmux versions that cannot resize panes', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.setHistoryLimit(200_000);
const historyCall = mockedExec.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
);
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -g history-limit 200000`);
expect(historyCall?.[0]).not.toContain(' -t ');
} finally {
nonTestManager.destroy();
}
});
it('targets only the new and tracked sessions on tmux 3.7+', async () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.endsWith(' -V')) return 'tmux 3.7b\n';
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'def67890-1234-5678-90ab-cdef12345678',
workingDir: '/project',
mode: 'shell',
historyLimit: 250_000,
});
const newSessionCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
);
expect(newSessionCall?.[0]).toBe(
`tmux -L 'codeman' new-session -ds "codeman-def67890" -c /tmp \\; set-option -t "codeman-def67890" history-limit 250000`
);
expect(newSessionCall?.[0]).not.toContain('set-option -g');
mockedExec.mockClear();
await nonTestManager.setHistoryLimit(200_000);
const historyCall = mockedExec.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
);
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -t 'codeman-def67890' history-limit 200000`);
expect(historyCall?.[0]).not.toContain('set-option -g');
} finally {
nonTestManager.destroy();
}
});
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();