/** * Pre-mount buffering for streamed PTY output. * * Output can arrive before `dees-terminal-view` has mounted, so frames are held until the view * exists. The rule that matters is that flushing the buffer must paint the same screen as * applying every frame live. That forbids the obvious "drop the oldest entries" bound, because * the oldest entry is exactly where an attachment's reconstructed screen state sits: dropping it * leaves the new terminal's tail painted on top of the previous terminal's screen. * * So there are only two sound reductions: * - a restore entry supersedes everything buffered, which both re-establishes a faithful * baseline and is the natural bound; and * - once the byte cap is passed with no restore to collapse against, the buffer can no longer * be flushed faithfully at all, so it is discarded and `overflowed` is raised. The consumer * must then re-attach for a fresh state rather than paint a corrupted screen. * * `ended` is carried on the state rather than only on an entry, so it survives an overflow that * discards the entries: a terminal whose PTY has ended cannot be re-attached for a fresh state. */ /** Grid a reconstructed screen state was serialized at; `DeesTerminalView.restore()` takes it. */ export interface IPendingTerminalRestoreGrid { cols: number; rows: number; } export interface IPendingTerminalOutputEntry { /** Absolute byte offset of this chunk in the terminal's output stream. */ offset: number; bytes: Uint8Array; /** * The bytes are a reconstructed screen state rather than live output: restore them into this * grid instead of writing them, which is also what makes the entry a faithful baseline. */ restore?: IPendingTerminalRestoreGrid; } export interface IPendingTerminalOutputState { entries: readonly IPendingTerminalOutputEntry[]; bytes: number; /** The buffer can no longer be flushed faithfully; the consumer must re-attach. */ overflowed: boolean; /** The PTY ended. Survives an overflow, because no re-attach can recover it. */ ended: boolean; } /** * Chosen well above what an attachment buffers before the view mounts: one reconstructed screen * state, whose reconstructed history the controller keeps under 512 KiB and whose screen costs * whatever the pty's own grid costs, plus the live tail that follows it. The cap is a backstop * for a terminal that floods faster than the view mounts, not a size the ordinary case approaches. */ export const maxPendingTerminalOutputBytes = 4 * 1024 * 1024; export const emptyPendingTerminalOutput = (): IPendingTerminalOutputState => ({ entries: [], bytes: 0, overflowed: false, ended: false, }); export const appendPendingTerminalOutput = ( stateArg: IPendingTerminalOutputState, entryArg: IPendingTerminalOutputEntry & { ended?: boolean }, maxBytesArg: number = maxPendingTerminalOutputBytes, ): IPendingTerminalOutputState => { const ended = stateArg.ended || entryArg.ended === true; const entry: IPendingTerminalOutputEntry = { offset: entryArg.offset, bytes: entryArg.bytes, ...(entryArg.restore ? { restore: entryArg.restore } : {}), }; // A restore re-establishes a faithful baseline, so it also clears a previous overflow. An empty // payload is kept: a terminal that shows nothing at that offset is a state like any other. if (entry.restore) { return { entries: [entry], bytes: entry.bytes.byteLength, overflowed: false, ended }; } // Already unflushable: hold no bytes, keep only the facts the consumer still needs. if (stateArg.overflowed) { return { entries: [], bytes: 0, overflowed: true, ended }; } if (entry.bytes.byteLength === 0) { return { entries: stateArg.entries, bytes: stateArg.bytes, overflowed: false, ended }; } const bytes = stateArg.bytes + entry.bytes.byteLength; if (bytes > maxBytesArg) { return { entries: [], bytes: 0, overflowed: true, ended }; } return { entries: [...stateArg.entries, entry], bytes, overflowed: false, ended }; };