Path: blob/master/src/packages/project/conat/terminal/session.ts
1450 views
import { spawn } from "@lydell/node-pty";1import { envForSpawn } from "@cocalc/backend/misc";2import { path_split } from "@cocalc/util/misc";3import { console_init_filename, len } from "@cocalc/util/misc";4import { exists } from "@cocalc/backend/misc/async-utils-node";5import { getLogger } from "@cocalc/project/logger";6import { readlink, realpath } from "node:fs/promises";7import { dstream, type DStream } from "@cocalc/project/conat/sync";8import {9createBrowserClient,10SIZE_TIMEOUT_MS,11} from "@cocalc/conat/service/terminal";12import { project_id, compute_server_id } from "@cocalc/project/data";13import { throttle } from "lodash";14import { ThrottleString as Throttle } from "@cocalc/util/throttle";15import { join } from "path";16import type { CreateTerminalOptions } from "@cocalc/conat/project/api/editor";17import { delay } from "awaiting";1819const logger = getLogger("project:conat:terminal:session");2021// truncated excessive INPUT is CRITICAL to avoid deadlocking the terminal22// and completely crashing the project in case a user pastes in, e.g.,23// a few hundred K, like this gist: https://gist.github.com/cheald/290588224// to a node session. Note VS code also crashes.25const MAX_INPUT_SIZE = 10000;26const INPUT_CHUNK_SIZE = 50;2728const EXIT_MESSAGE = "\r\n\r\n[Process completed - press any key]\r\n\r\n";2930const SOFT_RESET =31"tput rmcup; printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1l'; clear -x; sleep 0.1; clear -x";3233const COMPUTE_SERVER_INIT = `PS1="(\\h) \\w$ "; ${SOFT_RESET}; history -d $(history 1);\n`;3435const PROJECT_INIT = `${SOFT_RESET}; history -d $(history 1);\n`;3637const DEFAULT_COMMAND = "/bin/bash";38const INFINITY = 999999;3940const HISTORY_LIMIT_BYTES = parseInt(41process.env.COCALC_TERMINAL_HISTORY_LIMIT_BYTES ?? "1000000",42);4344// Limits that result in dropping messages -- this makes sense for a terminal (unlike a file you're editing).4546// Limit number of bytes per second in data:47const MAX_BYTES_PER_SECOND = parseInt(48process.env.COCALC_TERMINAL_MAX_BYTES_PER_SECOND ?? "1000000",49);5051// Hard limit at stream level the number of messages per second.52// However, the code in this file must already limit53// writing output less than this to avoid the stream ever54// having to discard writes. This is basically the "frame rate"55// we are supporting for users.56const MAX_MSGS_PER_SECOND = parseInt(57process.env.COCALC_TERMINAL_MAX_MSGS_PER_SECOND ?? "24",58);5960type State = "running" | "off" | "closed";6162export class Session {63public state: State = "off";64public options: CreateTerminalOptions;65private termPath: string;66private pty?;67private size?: { rows: number; cols: number };68private browserApi: ReturnType<typeof createBrowserClient>;69private stream?: DStream<string>;70private streamName: string;71private clientSizes: {72[browser_id: string]: { rows: number; cols: number; time: number };73} = {};74public pid: number;7576constructor({ termPath, options }) {77logger.debug("create session ", { termPath, options });78this.termPath = termPath;79this.browserApi = createBrowserClient({ project_id, termPath });80this.options = options;81this.streamName = `terminal-${termPath}`;82}8384kill = async () => {85if (this.stream == null) {86return;87}88await this.stream.delete({ all: true });89};9091write = async (data) => {92if (this.state == "off") {93await this.restart();94// don't write when it starts it, since this is often a carriage return or space,95// which you don't want to send except to start it.96return;97}98let reject;99if (data.length > MAX_INPUT_SIZE) {100data = data.slice(0, MAX_INPUT_SIZE);101reject = true;102} else {103reject = false;104}105for (106let i = 0;107i < data.length && this.pty != null;108i += INPUT_CHUNK_SIZE109) {110const chunk = data.slice(i, i + INPUT_CHUNK_SIZE);111this.pty.write(chunk);112// logger.debug("wrote data to pty", chunk.length);113await delay(1000 / MAX_MSGS_PER_SECOND);114}115if (reject) {116this.stream?.publish(`\r\n[excessive input discarded]\r\n\r\n`);117}118};119120restart = async () => {121this.pty?.destroy();122this.stream?.close();123delete this.pty;124await this.init();125};126127close = () => {128if (this.state != "off") {129this.stream?.publish(EXIT_MESSAGE);130}131this.pty?.destroy();132this.stream?.close();133delete this.pty;134delete this.stream;135this.state = "closed";136this.clientSizes = {};137};138139private getHome = () => {140return process.env.HOME ?? "/home/user";141};142143getCwd = async () => {144if (this.pty == null) {145return;146}147// we reply with the current working directory of the underlying terminal process,148// which is why we use readlink and proc below.149const pid = this.pty.pid;150// [hsy/dev] wrapping in realpath, because I had the odd case, where the project's151// home included a symlink, hence the "startsWith" below didn't remove the home dir.152const home = await realpath(this.getHome());153const cwd = await readlink(`/proc/${pid}/cwd`);154// try to send back a relative path, because the webapp does not155// understand absolute paths156const path = cwd.startsWith(home) ? cwd.slice(home.length + 1) : cwd;157return path;158};159160createStream = async () => {161this.stream = await dstream<string>({162name: this.streamName,163ephemeral: this.options.ephemeral,164config: {165max_bytes: HISTORY_LIMIT_BYTES,166max_bytes_per_second: MAX_BYTES_PER_SECOND,167// we throttle to less than MAX_MSGS_PER_SECOND client side, and168// have server impose a much higher limit, since messages can arrive169// in a group.170max_msgs_per_second: 5 * MAX_MSGS_PER_SECOND,171},172});173this.stream.publish("\r\n".repeat((this.size?.rows ?? 40) + 40));174this.stream.on("reject", () => {175this.throttledEllipses();176});177};178179private throttledEllipses = throttle(180() => {181this.stream?.publish(`\r\n[excessive output discarded]\r\n\r\n`);182},1831000,184{ leading: true, trailing: true },185);186187init = async () => {188const { head, tail } = path_split(this.termPath);189const HISTFILE = historyFile(this.options.path);190const env = {191PROMPT_COMMAND: "history -a",192...(HISTFILE ? { HISTFILE } : undefined),193...this.options.env,194...envForSpawn(),195COCALC_TERMINAL_FILENAME: tail,196TMUX: undefined, // ensure not set197};198const command = this.options.command ?? DEFAULT_COMMAND;199const args = this.options.args ?? [];200const initFilename: string = console_init_filename(this.termPath);201if (await exists(initFilename)) {202args.push("--init-file");203args.push(path_split(initFilename).tail);204}205if (this.state == "closed") {206return;207}208const cwd = getCWD(head, this.options.cwd);209logger.debug("creating pty");210this.pty = spawn(command, args, {211cwd,212env,213rows: this.size?.rows,214cols: this.size?.cols,215handleFlowControl: true,216});217this.pid = this.pty.pid;218if (command.endsWith("bash")) {219if (compute_server_id) {220// set the prompt to show the remote hostname explicitly,221// then clear the screen.222this.pty.write(COMPUTE_SERVER_INIT);223} else {224this.pty.write(PROJECT_INIT);225}226}227this.state = "running";228logger.debug("creating stream");229await this.createStream();230logger.debug("created the stream");231if ((this.state as State) == "closed") {232return;233}234logger.debug("connect stream to pty");235236// use slighlty less than MAX_MSGS_PER_SECOND to avoid reject237// due to being *slightly* off.238const throttle = new Throttle(1000 / (MAX_MSGS_PER_SECOND - 3));239throttle.on("data", (data: string) => {240// logger.debug("got data out of pty");241this.handleBackendMessages(data);242this.stream?.publish(data);243});244this.pty.onData(throttle.write);245246this.pty.onExit(() => {247this.stream?.publish(EXIT_MESSAGE);248this.state = "off";249});250};251252setSize = ({253browser_id,254rows,255cols,256kick,257}: {258browser_id: string;259rows: number;260cols: number;261kick?: boolean;262}) => {263if (kick) {264this.clientSizes = {};265}266this.clientSizes[browser_id] = { rows, cols, time: Date.now() };267this.resize();268};269270browserLeaving = (browser_id: string) => {271delete this.clientSizes[browser_id];272this.resize();273};274275private resize = async () => {276if (this.pty == null) {277// nothing to do278return;279}280const size = this.getSize();281if (size == null) {282return;283}284const { rows, cols } = size;285// logger.debug("resize", "new size", rows, cols);286try {287this.setSizePty({ rows, cols });288// tell browsers about our new size289await this.browserApi.size({ rows, cols });290} catch (err) {291logger.debug(`WARNING: unable to resize term: ${err}`);292}293};294295setSizePty = ({ rows, cols }: { rows: number; cols: number }) => {296// logger.debug("setSize", { rows, cols });297if (this.pty == null) {298// logger.debug("setSize: not doing since pty not defined");299return;300}301// logger.debug("setSize", { rows, cols }, "DOING IT!");302303// the underlying ptyjs library -- if it thinks the size is already set,304// it will do NOTHING. This ends up being very bad when clients reconnect.305// As a hack, we just change it, then immediately change it back306this.pty.resize(cols, rows + 1);307this.pty.resize(cols, rows);308this.size = { rows, cols };309};310311getSize = (): { rows: number; cols: number } | undefined => {312const sizes = this.clientSizes;313if (len(sizes) == 0) {314return;315}316let rows: number = INFINITY;317let cols: number = INFINITY;318const cutoff = Date.now() - SIZE_TIMEOUT_MS;319for (const id in sizes) {320if ((sizes[id].time ?? 0) <= cutoff) {321delete sizes[id];322continue;323}324if (sizes[id].rows) {325// if, since 0 rows or 0 columns means *ignore*.326rows = Math.min(rows, sizes[id].rows);327}328if (sizes[id].cols) {329cols = Math.min(cols, sizes[id].cols);330}331}332if (rows === INFINITY || cols === INFINITY) {333// no clients with known sizes currently visible334return;335}336// ensure valid values337rows = Math.max(rows ?? 1, rows);338cols = Math.max(cols ?? 1, cols);339// cache for future use.340this.size = { rows, cols };341return { rows, cols };342};343344private backendMessagesBuffer = "";345private backendMessagesState = "none";346347private resetBackendMessagesBuffer = () => {348this.backendMessagesBuffer = "";349this.backendMessagesState = "none";350};351352private handleBackendMessages = (data: string) => {353/* parse out messages like this:354\x1b]49;"valid JSON string here"\x07355and format and send them via our json channel.356*/357if (this.backendMessagesState === "none") {358const i = data.indexOf("\x1b]49;");359if (i == -1) {360return; // nothing to worry about361}362// stringify it so it is easy to see what is there:363this.backendMessagesState = "reading";364this.backendMessagesBuffer = data.slice(i);365} else {366this.backendMessagesBuffer += data;367}368if (this.backendMessagesBuffer.length >= 6) {369const i = this.backendMessagesBuffer.indexOf("\x07");370if (i == -1) {371// continue to wait... unless too long372if (this.backendMessagesBuffer.length > 10000) {373this.resetBackendMessagesBuffer();374}375return;376}377const s = this.backendMessagesBuffer.slice(5, i);378this.resetBackendMessagesBuffer();379logger.debug(380`handle_backend_message: parsing JSON payload ${JSON.stringify(s)}`,381);382let mesg;383try {384mesg = JSON.parse(s);385} catch (err) {386logger.warn(387`handle_backend_message: error sending JSON payload ${JSON.stringify(388s,389)}, ${err}`,390);391return;392}393(async () => {394try {395await this.browserApi.command(mesg);396} catch (err) {397// could fail, e.g., if there are no browser clients suddenly.398logger.debug(399"WARNING: problem sending command to browser clients",400err,401);402}403})();404}405};406}407408function getCWD(pathHead, cwd?): string {409// working dir can be set explicitly, and either be an empty string or $HOME410if (cwd != null) {411const HOME = process.env.HOME ?? "/home/user";412if (cwd === "") {413return HOME;414} else if (cwd.startsWith("$HOME")) {415return cwd.replace("$HOME", HOME);416} else {417return cwd;418}419}420return pathHead;421}422423function historyFile(path: string): string | undefined {424if (path.startsWith("/")) {425// only set histFile for paths in the home directory i.e.,426// relative to HOME. Absolute paths -- we just leave it alone.427// E.g., the miniterminal uses /tmp/... for its path.428return undefined;429}430const { head, tail } = path_split(path);431return join(432process.env.HOME ?? "",433head,434tail.endsWith(".term") ? tail : ".bash_history",435);436}437438439