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