Repository Public
1/**2 * Browser scrollback = a mirror of the tmux pane history, written by the runtime.3 *4 * `tmux attach` is a screen renderer: it repaints the visible pane and, when output arrives5 * faster than it flushes, it skips straight to the final screen. Lines that scrolled off in6 * between never reach the client, so the client's own scrollback cannot be fed by the attach7 * stream (measured with tmux 3.8: 300 lines printed at once left 29 of them in xterm.js).8 *9 * Design (see docs/CONTRACT.md "Terminal design"):10 * - the attach client is told its terminal has no alternate screen and no scroll region11 * (`terminal-overrides smcup@:rmcup@:csr@:indn@`): tmux then paints the pane in the normal12 * buffer and always *redraws* instead of scrolling, so it never pushes anything into the13 * client's scrollback by itself;14 * - the runtime is the only writer of that scrollback. On attach it replays the tail of the15 * pane history; while attached it appends exactly the lines tmux added to the history since16 * the last sync (`capture-pane` of grid lines [sent, history_size)). The lines are pushed17 * through a temporary two-row scroll region at the top of the screen, so only two rows of18 * the visible screen are disturbed; they are repainted in the same write. tmux is then made19 * to forget what it believes about the terminal (cursor, attributes) and to repaint it: the20 * attach client gets a SIGWINCH, which tmux answers with a full absolute redraw. That signal21 * also makes the client the "latest" one for `window-size latest`, so it is only sent while22 * the window already has this client's size; a client of another size (a second browser23 * that is not the one being typed in) is caught up by a full replay once sizes agree.24 * Whenever continuity cannot be proven (pane resized/reflowed, history cleared or trimmed,25 * more new lines than the replay window) the scrollback is cleared and replayed.26 *27 * Terminal bytes are never logged.28 */29import crypto from 'node:crypto';30import type { Tmux } from './tmux.js';31import { sleep } from './core-util.js';3233export const DEFAULT_REPLAY_LINES = 5000;34export const MAX_REPLAY_LINES = 20_000;35/** Upper bound of one replay, whatever the number of lines. */36export const MAX_REPLAY_BYTES = 2 * 1024 * 1024;37const FRAME_BYTES = 256 * 1024;38const SYNC_QUIET_MS = 60;39const SYNC_MAX_WAIT_MS = 250;40const SYNC_BUSY_RETRY_MS = 250;41/** Output that does not scroll (spinners, redraws): checks are spaced out, up to this. */42const SYNC_IDLE_MAX_MS = 1000;4344const ESC = '\x1b';45const SGR_OR_SHIFT = /\x1b\[([0-9;:]*)m|\x0e|\x0f/g;46const ANY_ESCAPE = /\x1b\[[0-9;:?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|[\x0e\x0f]/g;4748/** Text of a captured line without its escape sequences (used to prove continuity). */49export function plainText(line: string): string {50 return line.replace(ANY_ESCAPE, '');51}5253/**54 * Follows the background colour and the SO/SI charset shift across captured lines.55 * `capture-pane -e` emits attribute changes relative to the previous line, and a terminal fills56 * the row created by a line feed with the current background: the background (and SO) must be57 * dropped around every line break and restored after it.58 */59export class LineState {60 /** SGR parameters that select the current background, or null for the default. */61 bg: string | null = null;62 shiftOut = false;6364 feed(line: string): void {65 for (const m of line.matchAll(SGR_OR_SHIFT)) {66 if (m[0] === '\x0e') this.shiftOut = true;67 else if (m[0] === '\x0f') this.shiftOut = false;68 else this.sgr(m[1] ?? '');69 }70 }7172 private sgr(params: string): void {73 const t = params === '' ? ['0'] : params.split(';');74 for (let i = 0; i < t.length; i++) {75 const tok = t[i] ?? '';76 if (tok.includes(':')) {77 if (tok.startsWith('48:')) this.bg = tok;78 continue;79 }80 const n = tok === '' ? 0 : Number(tok);81 if (n === 0) this.bg = null;82 else if (n === 49) this.bg = null;83 else if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) this.bg = tok;84 else if (n === 38 || n === 48 || n === 58) {85 const len = t[i + 1] === '5' ? 2 : t[i + 1] === '2' ? 4 : 0;86 if (n === 48 && len > 0) this.bg = t.slice(i, i + 1 + len).join(';');87 i += len;88 }89 }90 }91}9293/**94 * True while the forwarded byte stream is between two complete characters / escape sequences,95 * i.e. where foreign bytes can be inserted without corrupting a sequence.96 */97export class StreamBoundary {98 private state: 'ground' | 'esc' | 'escInter' | 'csi' | 'str' | 'strEsc' = 'ground';99 private utf8Pending = 0;100101 get clean(): boolean {102 return this.state === 'ground' && this.utf8Pending === 0;103 }104105 feed(buf: Uint8Array): void {106 let state = this.state;107 let pending = this.utf8Pending;108 for (let i = 0; i < buf.length; i++) {109 const b = buf[i] as number;110 if (b === 0x18 || b === 0x1a) {111 state = 'ground';112 pending = 0;113 continue;114 }115 switch (state) {116 case 'ground':117 if (b === 0x1b) {118 state = 'esc';119 pending = 0;120 } else if (b >= 0x80 && b < 0xc0) pending = pending > 0 ? pending - 1 : 0;121 else if (b >= 0xf0) pending = 3;122 else if (b >= 0xe0) pending = 2;123 else if (b >= 0xc0) pending = 1;124 else pending = 0;125 break;126 case 'esc':127 if (b === 0x5b) state = 'csi';128 else if (b === 0x5d || b === 0x50 || b === 0x5f || b === 0x5e || b === 0x58) state = 'str';129 else if (b >= 0x20 && b <= 0x2f) state = 'escInter';130 else if (b !== 0x1b) state = 'ground';131 break;132 case 'escInter':133 if (b >= 0x30 && b <= 0x7e) state = 'ground';134 else if (b === 0x1b) state = 'esc';135 break;136 case 'csi':137 if (b >= 0x40 && b <= 0x7e) state = 'ground';138 else if (b === 0x1b) state = 'esc';139 break;140 case 'str':141 if (b === 0x07) state = 'ground';142 else if (b === 0x1b) state = 'strEsc';143 break;144 case 'strEsc':145 state = b === 0x5c ? 'ground' : b === 0x1b ? 'strEsc' : 'str';146 break;147 }148 }149 this.state = state;150 this.utf8Pending = pending;151 }152}153154export interface InjectionOptions {155 /** History lines to append to the client's scrollback, oldest first (may hold SGR sequences). */156 lines: readonly string[];157 /** Drop the client's scrollback first (full replay). */158 clear: boolean;159 /**160 * The terminal is showing a live tmux screen: abort any partial sequence, then put the two161 * rows, the attributes and the cursor back (best effort: the caller makes tmux repaint right162 * after). False for the replay that precedes the attach (blank terminal).163 */164 live: boolean;165 /** Content of the first two visible rows, repainted after the push (live only). */166 top: readonly string[] | null;167 /** Pane cursor, 0-based (live only). */168 cursor?: { x: number; y: number } | null;169}170171/**172 * Bytes that append `lines` to the scrollback of an xterm-compatible terminal.173 *174 * A two-row scroll region is set at the top of the screen and every line is written through175 * it: each line feed at its bottom pushes row 1 into the scrollback (a region starting at the176 * first row scrolls into the scrollback) and leaves the rows below the region untouched. Long177 * lines wrap inside the region, so the terminal keeps its own soft-wrap marks.178 */179export function buildInjection(o: InjectionOptions): Buffer {180 let out = '';181 if (o.live) out += `\x18${ESC}7`;182 out += `${ESC}[m`;183 if (o.clear) out += `${ESC}[3J`;184 if (o.lines.length > 0) {185 // Region rows 1-2 (the cursor goes home), both rows blanked with the default background.186 out += `${ESC}[1;2r${ESC}[H${ESC}[2K\n${ESC}[2K${ESC}[H`;187 const st = new LineState();188 for (const line of o.lines) {189 st.feed(line);190 out += line;191 if (st.bg !== null) out += `${ESC}[49m`;192 if (st.shiftOut) out += '\x0f';193 out += '\r\n';194 if (st.shiftOut) out += '\x0e';195 if (st.bg !== null) out += `${ESC}[${st.bg}m`;196 }197 // The last line sits in row 1 with the cursor below it: one more line feed pushes it out.198 out += `${st.shiftOut ? '\x0f' : ''}${ESC}[m\n${ESC}[r`;199 }200 if (o.live) {201 if (o.top && o.lines.length > 0) {202 const st = new LineState();203 for (const row of o.top) st.feed(row);204 out += `${ESC}[H${ESC}[2K${ESC}[2;1H${ESC}[2K${ESC}[H${o.top[0] ?? ''}${ESC}[2;1H${o.top[1] ?? ''}${st.shiftOut ? '\x0f' : ''}${ESC}[m`;205 }206 // DECRC brings the attributes back; its row is relative to a scrollback that just moved.207 out += `${ESC}8`;208 if (o.cursor) out += `${ESC}[${o.cursor.y + 1};${o.cursor.x + 1}H`;209 }210 return Buffer.from(out, 'utf8');211}212213export interface HistoryCapture {214 /** Lines in the pane history when the capture was taken. */215 hsize: number;216 hlimit: number;217 cols: number;218 rows: number;219 /** Captured history lines (wrapped rows joined), oldest first. */220 lines: string[];221 /** The capture stopped on a row that wraps onto the next one. */222 endsWrapped: boolean;223 /** First two rows of the visible screen. */224 top: string[];225 cursor: { x: number; y: number };226}227228/**229 * One tmux invocation (atomic for the server): history size + the requested history range +230 * the first two visible rows. `from` is an absolute history index, or null for "the last231 * `cap` lines". Returns null when the session is gone.232 */233export async function captureHistory(tmux: Tmux, name: string, from: number | null, cap: number): Promise<HistoryCapture | null> {234 const target = `=${name}:`;235 const mark = `cr-mark-${crypto.randomBytes(16).toString('hex')}`;236 // `from - history_size` is the (negative) offset of the first wanted line, bounded by -cap.237 const start = from === null ? `-${cap}` : `#{?#{e|>|:#{e|-|:#{history_size},${from}},${cap}},-${cap},#{e|-|:${from},#{history_size}}}`;238 let out: string;239 try {240 out = await tmux.run([241 'display-message', '-p', '-t', target, '#{history_size}|#{history_limit}|#{pane_width}|#{pane_height}|#{cursor_x}|#{cursor_y}', ';',242 'display-message', '-p', mark, ';',243 'capture-pane', '-p', '-e', '-J', '-t', target, '-S', start, '-E', '-1', ';',244 'display-message', '-p', mark, ';',245 'capture-pane', '-p', '-e', '-t', target, '-S', '0', '-E', '1',246 ]);247 } catch {248 return null;249 }250 const parts = out.split(`${mark}\n`);251 if (parts.length !== 3) return null;252 const head = (parts[0] ?? '').trim().split('|').map(Number);253 if (head.length !== 6 || head.some((n) => !Number.isFinite(n))) return null;254 const [hsize, hlimit, cols, rows, x, y] = head as [number, number, number, number, number, number];255 const body = parts[1] ?? '';256 const endsWrapped = body.length > 0 && !body.endsWith('\n');257 const lines = body.split('\n');258 if (!endsWrapped) lines.pop();259 // With an empty range tmux still captures one line of the visible screen: not history.260 const wanted = from === null ? hsize : hsize - from;261 const top = (parts[2] ?? '').split('\n').slice(0, 2);262 return { hsize, hlimit, cols, rows, lines: wanted > 0 ? lines : [], endsWrapped: wanted > 0 && endsWrapped, top, cursor: { x, y } };263}264265export interface MirrorDeps {266 tmux: Tmux;267 /** tmux session name (`cr-<uuid>`). */268 name: string;269 /** Lines replayed on attach / full resync. */270 replayLines: number;271 /** Writes terminal bytes to the browser. Chunks of one call are sent back to back. */272 send(chunk: Buffer): void;273 /** Size of the client's terminal right now. */274 size(): { cols: number; rows: number };275 /** True while the connection is saturated (no injection then). */276 busy(): boolean;277 /** Makes tmux repaint this client from scratch (SIGWINCH to the attach client). */278 redraw(): void;279 onError(err: unknown): void;280}281282export class ScrollbackMirror {283 private readonly boundary = new StreamBoundary();284 /** Absolute history index up to which the client's scrollback is known to be complete. */285 private sent: number | null = null;286 /** Plain text of the last history line sent, when it was a complete line. */287 private lastPlain: string | null = null;288 private dims: { cols: number; rows: number } | null = null;289 private needFull = true;290 private timer: NodeJS.Timeout | null = null;291 private firstPendingAt = 0;292 /** Back-off while the output adds nothing to the history. */293 private idleMs = 0;294 private notBefore = 0;295 private running = false;296 private again = false;297 private disposed = false;298299 constructor(private readonly d: MirrorDeps) {}300301 /** Replays the history tail into a blank terminal. Call before the attach PTY is spawned. */302 async replay(): Promise<void> {303 await this.sync(false);304 }305306 /** Every chunk forwarded from the attach PTY, in order. */307 noteOutput(chunk: Uint8Array): void {308 this.boundary.feed(chunk);309 this.schedule(SYNC_QUIET_MS);310 }311312 /** The client's terminal changed size: its own reflow moved lines, so replay from scratch. */313 noteResize(): void {314 this.needFull = true;315 this.idleMs = 0;316 this.notBefore = 0;317 this.schedule(SYNC_QUIET_MS);318 }319320 dispose(): void {321 this.disposed = true;322 if (this.timer) clearTimeout(this.timer);323 this.timer = null;324 }325326 private schedule(quietMs: number): void {327 if (this.disposed || this.sent === null) return; // nothing before the initial replay328 const now = Date.now();329 if (this.timer) clearTimeout(this.timer);330 else this.firstPendingAt = now;331 const at = Math.max(Math.min(now + quietMs, this.firstPendingAt + SYNC_MAX_WAIT_MS), this.notBefore);332 this.timer = setTimeout(() => {333 this.timer = null;334 void this.tick();335 }, Math.max(0, at - now));336 this.timer.unref();337 }338339 private async tick(): Promise<void> {340 if (this.disposed) return;341 if (this.running) {342 this.again = true;343 return;344 }345 if (this.d.busy()) return this.schedule(SYNC_BUSY_RETRY_MS);346 this.running = true;347 try {348 await this.sync(true);349 } catch (err) {350 this.d.onError(err);351 } finally {352 this.running = false;353 if (this.again && !this.disposed) {354 this.again = false;355 this.schedule(SYNC_QUIET_MS);356 }357 }358 }359360 private async sync(live: boolean): Promise<void> {361 const { tmux, name, replayLines } = this.d;362 const sized = (c: HistoryCapture): boolean => {363 const size = this.d.size();364 return c.cols === size.cols && c.rows === size.rows;365 };366 let full = this.needFull || this.sent === null;367 let from = this.sent ?? 0;368 let cap: HistoryCapture | null = null;369 let lines: string[] = [];370 for (let attempt = 0; attempt < 4; attempt++) {371 if (attempt === 3) full = true;372 // One extra line (the last one already sent) proves that the indices still line up.373 const verify = !full && from > 0 && this.lastPlain !== null;374 cap = await captureHistory(tmux, name, full ? null : from - (verify ? 1 : 0), replayLines + 1);375 if (this.disposed || !cap) return;376 if (live && !sized(cap)) {377 // The window has another client's size (or our resize has not reached tmux yet):378 // touching this client now would fight over `window-size latest`. Replay later.379 this.needFull = true;380 return;381 }382 if (full) {383 lines = cap.lines.slice(-replayLines);384 break;385 }386 if (this.dims && (cap.cols !== this.dims.cols || cap.rows !== this.dims.rows)) {387 full = true; // the pane was resized: tmux reflowed its history388 continue;389 }390 if (cap.hsize < from) {391 // At `history-limit` tmux drops the oldest tenth of the history in one go.392 const step = Math.max(1, Math.floor(cap.hlimit / 10));393 if (cap.hsize >= cap.hlimit - step && from - step >= 0) from -= step;394 else full = true; // history cleared395 continue;396 }397 if (cap.hsize - from > replayLines) {398 full = true;399 continue;400 }401 lines = cap.lines;402 if (verify) {403 const first = lines.shift();404 if (first === undefined || plainText(first) !== this.lastPlain) {405 full = true;406 continue;407 }408 }409 break;410 }411 if (!cap) return;412413 this.needFull = false;414 this.sent = cap.hsize;415 this.dims = { cols: cap.cols, rows: cap.rows };416 if (lines.length > 0 || full) {417 const last = lines.at(-1);418 this.lastPlain = last !== undefined && !cap.endsWrapped ? plainText(last) : null;419 }420 if (lines.length === 0 && !full) {421 // Nothing scrolled: look less often until something does.422 this.idleMs = Math.min(SYNC_IDLE_MAX_MS, Math.max(SYNC_MAX_WAIT_MS, this.idleMs * 2));423 this.notBefore = Date.now() + this.idleMs;424 return;425 }426 this.idleMs = 0;427 this.notBefore = 0;428429 // Bound the replay: keep the newest lines.430 let bytes = 0;431 let keepFrom = lines.length;432 while (keepFrom > 0) {433 const size = Buffer.byteLength(lines[keepFrom - 1] as string) + 16;434 if (bytes + size > MAX_REPLAY_BYTES) break;435 bytes += size;436 keepFrom--;437 }438 if (keepFrom > 0) lines = lines.slice(keepFrom);439440 if (live) {441 for (let i = 0; i < 20 && !this.boundary.clean && !this.disposed; i++) await sleep(5);442 if (this.disposed) return;443 }444 const payload = buildInjection({ lines, clear: full, live, top: live ? cap.top : null, cursor: live ? cap.cursor : null });445 for (let off = 0; off < payload.length; off += FRAME_BYTES) this.d.send(payload.subarray(off, off + FRAME_BYTES));446 if (live) this.d.redraw();447 }448}449