mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Merge pull request #331 from dignfei/fix/shell-history-performance
fix(terminal): bound shell history replay
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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() {');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user