Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/terminal/lib/remote-terminal.ts
Views: 687
/*1Terminal instance that runs on a remote machine.23This is a sort of simpler mirror image of terminal.ts.45This provides a terminal via the "remotePty" mechanism to a project.6The result feels a bit like "ssh'ing to a remote machine", except7the connection comes from the outside over a websocket. When you're8actually using it, though, it's identical to if you ssh out.910[remote.ts Terminal] ------------> [Project]1112This works in conjunction with src/compute/compute/terminal13*/1415import getLogger from "@cocalc/backend/logger";16import { spawn } from "node-pty";17import type { Options, IPty } from "./types";18import type { Channel } from "@cocalc/comm/websocket/types";19import { readlink, realpath, writeFile } from "node:fs/promises";20import { EventEmitter } from "events";21import { getRemotePtyChannelName } from "./util";22import { REMOTE_TERMINAL_HEARTBEAT_INTERVAL_MS } from "./terminal";23import { throttle } from "lodash";24import { join } from "path";25import { delay } from "awaiting";2627// NOTE: shorter than terminal.ts. This is like "2000 lines."28const MAX_HISTORY_LENGTH = 100 * 2000;2930const logger = getLogger("terminal:remote");3132type State = "init" | "ready" | "closed";3334export class RemoteTerminal extends EventEmitter {35private state: State = "init";36private websocket;37private path: string;38private conn: Channel;39private cwd?: string;40private env?: object;41private localPty?: IPty;42private options?: Options;43private size?: { rows: number; cols: number };44private computeServerId?: number;45private history: string = "";46private lastData: number = 0;47private healthCheckInterval;4849constructor(50websocket,51path,52{ cwd, env }: { cwd?: string; env?: object } = {},53computeServerId?,54) {55super();56this.computeServerId = computeServerId;57this.path = path;58this.websocket = websocket;59this.cwd = cwd;60this.env = env;61logger.debug("create ", { cwd });62this.connect();63this.waitUntilHealthy();64}6566// Why we do this initially is subtle. Basically right when the user opens67// a terminal, the project maybe hasn't set up anything, so there is no68// channel to connect to. The project then configures things, but it doesn't,69// initially see this remote server, which already tried to connect to a channel70// that I guess didn't exist. So we check if we got any response at all, and if71// not we try again, with exponential backoff up to 10s. Once we connect72// and get a response, we switch to about 10s heartbeat checking as usual.73// There is probably a different approach to solve this problem, depending on74// better understanding the async nature of channels, but this does work well.75// Not doing this led to a situation where it always initially took 10.5s76// to connect, which sucks!77private waitUntilHealthy = async () => {78let d = 250;79while (this.state != "closed") {80if (this.isHealthy()) {81this.initRegularHealthChecks();82return;83}84d = Math.min(10000, d * 1.25);85await delay(d);86}87};8889private isHealthy = () => {90if (this.state == "closed") {91return true;92}93if (94Date.now() - this.lastData >=95REMOTE_TERMINAL_HEARTBEAT_INTERVAL_MS + 3000 &&96this.websocket.state == "online"97) {98logger.debug("websocket online but no heartbeat so reconnecting");99this.reconnect();100return false;101}102return true;103};104105private initRegularHealthChecks = () => {106this.healthCheckInterval = setInterval(107this.isHealthy,108REMOTE_TERMINAL_HEARTBEAT_INTERVAL_MS + 3000,109);110};111112private reconnect = () => {113logger.debug("reconnect");114this.conn.removeAllListeners();115this.conn.end();116this.connect();117};118119private connect = () => {120if (this.state == "closed") {121return;122}123const name = getRemotePtyChannelName(this.path);124logger.debug(this.path, "connect: channel=", name);125this.conn = this.websocket.channel(name);126this.conn.on("data", async (data) => {127// DO NOT LOG EXCEPT FOR VERY LOW LEVEL TEMPORARY DEBUGGING!128// logger.debug(this.path, "channel: data", data);129try {130await this.handleData(data);131} catch (err) {132logger.debug(this.path, "error handling data -- ", err);133}134});135this.conn.on("end", async () => {136logger.debug(this.path, "channel: end");137});138this.conn.on("close", async () => {139logger.debug(this.path, "channel: close");140this.reconnect();141});142if (this.computeServerId != null) {143logger.debug(144this.path,145"connect: sending computeServerId =",146this.computeServerId,147);148this.conn.write({ cmd: "setComputeServerId", id: this.computeServerId });149}150};151152close = () => {153this.state = "closed";154this.emit("closed");155this.removeAllListeners();156this.conn.end();157if (this.healthCheckInterval) {158clearInterval(this.healthCheckInterval);159}160};161162private handleData = async (data) => {163if (this.state == "closed") return;164this.lastData = Date.now();165if (typeof data == "string") {166if (this.localPty != null) {167this.localPty.write(data);168} else {169logger.debug("no pty active, but got data, so let's spawn one locally");170const pty = await this.initLocalPty();171if (pty != null) {172// we delete first character since it is the "any key"173// user hit to get terminal going.174pty.write(data.slice(1));175}176}177} else {178// console.log("COMMAND", data);179switch (data.cmd) {180case "init":181this.options = data.options;182this.size = data.size;183await this.initLocalPty();184logger.debug("sending history of length", this.history.length);185this.conn.write(this.history);186break;187188case "size":189if (this.localPty != null) {190this.localPty.resize(data.cols, data.rows);191}192break;193194case "cwd":195await this.sendCurrentWorkingDirectoryLocalPty();196break;197198case undefined:199// logger.debug("received empty data (heartbeat)");200break;201}202}203};204205private initLocalPty = async () => {206if (this.state == "closed") return;207if (this.options == null) {208return;209}210if (this.localPty != null) {211return;212}213const command = this.options.command ?? "/bin/bash";214const args = this.options.args ?? [];215const cwd = this.cwd ?? this.options.cwd;216logger.debug("initLocalPty: spawn -- ", {217command,218args,219cwd,220size: this.size ? this.size : "size not defined",221});222223const localPty = spawn(command, args, {224cwd,225env: { ...this.options.env, ...this.env },226rows: this.size?.rows,227cols: this.size?.cols,228}) as IPty;229this.state = "ready";230logger.debug("initLocalPty: pid=", localPty.pid);231232localPty.onExit(() => {233delete this.localPty; // no longer valid234this.conn.write({ cmd: "exit" });235});236237this.localPty = localPty;238if (this.size) {239this.localPty.resize(this.size.cols, this.size.rows);240}241242localPty.onData((data) => {243this.conn.write(data);244245this.history += data;246const n = this.history.length;247if (n >= MAX_HISTORY_LENGTH) {248logger.debug("terminal data -- truncating");249this.history = this.history.slice(n - MAX_HISTORY_LENGTH / 2);250}251this.saveHistoryToDisk();252});253254// set the prompt to show the remote hostname explicitly,255// then clear the screen.256if (command == "/bin/bash") {257this.localPty.write('PS1="(\\h) \\w$ ";reset;history -d $(history 1)\n');258// alternative -- this.localPty.write('PS1="(\\h) \\w$ "\n');259}260261return this.localPty;262};263264private getHome = () => {265return this.env?.["HOME"] ?? process.env.HOME ?? "/home/user";266};267268private sendCurrentWorkingDirectoryLocalPty = async () => {269if (this.localPty == null) {270return;271}272// we reply with the current working directory of the underlying273// terminal process, which is why we use readlink and proc below.274const pid = this.localPty.pid;275const home = await realpath(this.getHome());276const cwd = await readlink(`/proc/${pid}/cwd`);277const path = cwd.startsWith(home) ? cwd.slice(home.length + 1) : cwd;278logger.debug("terminal cwd sent back", { path });279this.conn.write({ cmd: "cwd", payload: path });280};281282private saveHistoryToDisk = throttle(async () => {283const target = join(this.getHome(), this.path);284try {285await writeFile(target, this.history);286} catch (err) {287logger.debug(288`WARNING: failed to save terminal history to '${target}'`,289err,290);291}292}, 15000);293}294295296297