mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(terminal): bound live xterm backpressure
This commit is contained in:
@@ -225,10 +225,11 @@ describe('OpenCode session initial resize', () => {
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// Dispatch the needsRefresh event directly on the EventSource
|
||||
// (this is how the server sends SSE events — as named events)
|
||||
// Exercise the SSE fallback path. While WebSocket owns terminal I/O these
|
||||
// duplicate SSE terminal events are intentionally ignored.
|
||||
await page.evaluate((sid: string) => {
|
||||
const app = (window as unknown as { app: { eventSource: EventSource } }).app;
|
||||
const app = (window as unknown as { app: { eventSource: EventSource; _disconnectWs: () => void } }).app;
|
||||
app._disconnectWs();
|
||||
if (app.eventSource) {
|
||||
const event = new MessageEvent('session:needsRefresh', {
|
||||
data: JSON.stringify({ id: sid }),
|
||||
|
||||
@@ -38,7 +38,10 @@ function loadTerminalUiHarness(mode: string) {
|
||||
app._workerYield = () => {};
|
||||
app._chunkedWriteGen = 0;
|
||||
app.terminal = {
|
||||
write: (data: string) => writes.push(data),
|
||||
write: (data: string, callback?: () => void) => {
|
||||
writes.push(data);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom: () => {},
|
||||
scrollToLine: () => {},
|
||||
};
|
||||
@@ -46,7 +49,90 @@ function loadTerminalUiHarness(mode: string) {
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
function loadAppHarness() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const fetchMock = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance: { now: () => 0 },
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: fetchMock,
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
});
|
||||
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
|
||||
const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
return { CodemanApp, fetchMock };
|
||||
}
|
||||
|
||||
describe('terminal flush budget', () => {
|
||||
it('counts incoming, loading, and xterm in-flight bytes before accepting live output', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const start = source.indexOf('_onSessionTerminal(data)');
|
||||
const body = source.slice(start, source.indexOf('\n // ═', start));
|
||||
|
||||
expect(body).toContain('this._loadBufferQueue?.reduce');
|
||||
expect(body).toContain('this._terminalWriteInFlightBytes || 0');
|
||||
expect(body).toContain('queued + data.data.length > 131072');
|
||||
});
|
||||
|
||||
it('drops redundant SSE terminal events whenever WebSocket owns terminal I/O', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
app._wsReady = true;
|
||||
app._onSessionTerminal = vi.fn();
|
||||
app._onSessionNeedsRefresh = vi.fn();
|
||||
app._onSessionClearTerminal = vi.fn();
|
||||
|
||||
app._onSSETerminal({ id: 'session-1', data: 'duplicate' });
|
||||
app._onSSENeedsRefresh({});
|
||||
app._onSSEClearTerminal({ id: 'session-1' });
|
||||
|
||||
expect(app._onSessionTerminal).not.toHaveBeenCalled();
|
||||
expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled();
|
||||
expect(app._onSessionClearTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs at most one buffer recovery per session and ignores stale-session events', async () => {
|
||||
const { CodemanApp, fetchMock } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
app.activeSessionId = 'session-1';
|
||||
app.sessions = new Map([['session-1', { mode: 'shell' }]]);
|
||||
app.terminal = {};
|
||||
app._isLoadingBuffer = false;
|
||||
app._terminalRefreshOwner = null;
|
||||
|
||||
let releaseFetch!: () => void;
|
||||
fetchMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolveFetch) => {
|
||||
releaseFetch = () => resolveFetch({ json: async () => ({ data: { terminalBuffer: '' } }) });
|
||||
})
|
||||
);
|
||||
|
||||
await app._onSessionNeedsRefresh({ id: 'stale-session' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const first = app._onSessionNeedsRefresh({ id: 'session-1' });
|
||||
const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576');
|
||||
|
||||
releaseFetch();
|
||||
await Promise.all([first, duplicate]);
|
||||
expect(app._terminalRefreshOwner).toBe(null);
|
||||
});
|
||||
|
||||
it('drains a large final batch without waiting for unrelated terminal output', () => {
|
||||
const { app, writes } = loadTerminalUiHarness('codex');
|
||||
const scheduled: Array<() => void> = [];
|
||||
@@ -89,6 +175,32 @@ describe('terminal flush budget', () => {
|
||||
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
||||
});
|
||||
|
||||
it('waits for xterm to parse a live chunk before submitting the next one', () => {
|
||||
const { app, writes } = loadTerminalUiHarness('shell');
|
||||
const scheduled: Array<() => void> = [];
|
||||
let parsed: (() => void) | undefined;
|
||||
app._safeYield = (callback: () => void) => scheduled.push(callback);
|
||||
app.isTerminalAtBottom = () => true;
|
||||
app.terminal.write = (data: string, callback?: () => void) => {
|
||||
writes.push(data);
|
||||
parsed = callback;
|
||||
};
|
||||
|
||||
app.batchTerminalWrite('x'.repeat(96 * 1024));
|
||||
scheduled.shift()?.();
|
||||
|
||||
expect(writes.map((write) => write.length)).toEqual([64 * 1024]);
|
||||
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
||||
expect(scheduled).toHaveLength(0);
|
||||
expect(app._terminalWriteInFlightBytes).toBe(64 * 1024);
|
||||
|
||||
parsed?.();
|
||||
|
||||
expect(scheduled).toHaveLength(1);
|
||||
scheduled.shift()?.();
|
||||
expect(writes.map((write) => write.length)).toEqual([64 * 1024, 32 * 1024]);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => {
|
||||
|
||||
it('is wired into the refresh path instead of an unconditional scrollToBottom', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = app.indexOf('async _onSessionNeedsRefresh()');
|
||||
const start = app.indexOf('async _onSessionNeedsRefresh(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
|
||||
expect(body).toContain('computeRewriteScrollLine');
|
||||
@@ -199,18 +199,16 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => {
|
||||
expect(body).toContain('this.terminal.scrollToLine(target)');
|
||||
});
|
||||
|
||||
it('recovers FULL history, guarded against a repaint-pane downgrade', () => {
|
||||
// Measured before the fix: this path rewrote an 869-row buffer from a 1MB
|
||||
// tail and left 158 rows, so the refresh meant to REPAIR the terminal was
|
||||
// destroying most of its scrollback. It asks for full history now, and
|
||||
// falls back to the tail only when the full capture would shrink the buffer
|
||||
// (a repaint-mode pane keeps roughly one frame in tmux).
|
||||
it('keeps shell recovery bounded and full TUI recovery downgrade-safe', () => {
|
||||
// A shell's automatic recovery must not reset+replay a multi-megabyte tmux
|
||||
// history on xterm's main thread. TUI modes still recover full history and
|
||||
// fall back when a repaint-mode pane would shrink the browser buffer.
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = app.indexOf('async _onSessionNeedsRefresh()');
|
||||
const start = app.indexOf('async _onSessionNeedsRefresh(');
|
||||
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
|
||||
expect(body).toContain("const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell'");
|
||||
expect(body).toContain('terminal?full=1');
|
||||
expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)');
|
||||
// The tail must survive as the fallback, not vanish.
|
||||
expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}');
|
||||
expect(body).toContain('useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user