CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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