SPB Git

Repository Public

core-scrollback.ts 17.3 KB 449 lines typescript

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
Files main
Menu

Theme