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/project/project-info/server.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Project information server, doing the heavy lifting of telling the client
8
about what's going on in a project.
9
*/
10
11
import { delay } from "awaiting";
12
import type { DiskUsage as DF_DiskUsage } from "diskusage";
13
import { check as df } from "diskusage";
14
import { EventEmitter } from "node:events";
15
import { readFile } from "node:fs/promises";
16
17
import { ProcessStats } from "@cocalc/backend/process-stats";
18
import { get_kernel_by_pid } from "@cocalc/jupyter/kernel";
19
import { pidToPath as terminalPidToPath } from "@cocalc/terminal";
20
import {
21
CGroup,
22
CoCalcInfo,
23
DiskUsage,
24
Process,
25
Processes,
26
ProjectInfo,
27
} from "@cocalc/util/types/project-info/types";
28
import { get_path_for_pid as x11_pid2path } from "../x11/server";
29
//import { get_sage_path } from "../sage_session"
30
import { getLogger } from "../logger";
31
32
const L = getLogger("project-info:server").debug;
33
34
// function is_in_dev_project() {
35
// return process.env.SMC_LOCAL_HUB_HOME != null;
36
// }
37
38
const bytes2MiB = (bytes) => bytes / (1024 * 1024);
39
40
export class ProjectInfoServer extends EventEmitter {
41
private last?: ProjectInfo = undefined;
42
private readonly dbg: Function;
43
private running = false;
44
private readonly testing: boolean;
45
private delay_s: number;
46
private cgroupFilesAreMissing: boolean = false;
47
private processStats: ProcessStats;
48
49
constructor(testing = false) {
50
super();
51
this.delay_s = 2;
52
this.testing = testing;
53
this.dbg = L;
54
}
55
56
private async processes(timestamp: number) {
57
return await this.processStats.processes(timestamp);
58
}
59
60
// delta-time for this and the previous process information
61
private dt(timestamp) {
62
return (timestamp - (this.last?.timestamp ?? 0)) / 1000;
63
}
64
65
public latest(): ProjectInfo | undefined {
66
return this.last;
67
}
68
69
// for a process we know (pid, etc.) we try to map to cocalc specific information
70
private cocalc({
71
pid,
72
cmdline,
73
}: Pick<Process, "pid" | "cmdline">): CoCalcInfo | undefined {
74
//this.dbg("classify", { pid, exe, cmdline });
75
if (pid === process.pid) {
76
return { type: "project" };
77
}
78
// TODO use get_sage_path to get a path to a sagews
79
const jupyter_kernel = get_kernel_by_pid(pid);
80
if (jupyter_kernel != null) {
81
return { type: "jupyter", path: jupyter_kernel.get_path() };
82
}
83
const termpath = terminalPidToPath(pid);
84
if (termpath != null) {
85
return { type: "terminal", path: termpath };
86
}
87
const x11_path = x11_pid2path(pid);
88
if (x11_path != null) {
89
return { type: "x11", path: x11_path };
90
}
91
// SSHD: strangely, just one long string in cmdline[0]
92
if (
93
cmdline.length === 1 &&
94
cmdline[0].startsWith("sshd:") &&
95
cmdline[0].indexOf("-p 2222") != -1
96
) {
97
return { type: "sshd" };
98
}
99
}
100
101
private lookupCoCalcInfo(processes: Processes) {
102
// iterate over all processes keys (pid) and call this.cocalc({pid, cmdline})
103
// to update the processes coclc field
104
for (const pid in processes) {
105
processes[pid].cocalc = this.cocalc({
106
pid: parseInt(pid),
107
cmdline: processes[pid].cmdline,
108
});
109
}
110
}
111
112
// this is specific to running a project in a CGroup container
113
// Harald: however, even without a container this shouldn't fail … just tells
114
// you what the whole system is doing, all your processes.
115
// William: it's constantly failing in cocalc-docker every second, so to avoid
116
// clogging logs and wasting CPU, if the files are missing once, it stops updating.
117
private async cgroup({ timestamp }): Promise<CGroup | undefined> {
118
if (this.cgroupFilesAreMissing) {
119
return;
120
}
121
try {
122
const [mem_stat_raw, cpu_raw, oom_raw, cfs_quota_raw, cfs_period_raw] =
123
await Promise.all([
124
readFile("/sys/fs/cgroup/memory/memory.stat", "utf8"),
125
readFile("/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage", "utf8"),
126
readFile("/sys/fs/cgroup/memory/memory.oom_control", "utf8"),
127
readFile("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us", "utf8"),
128
readFile("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us", "utf8"),
129
]);
130
const mem_stat_keys = [
131
"total_rss",
132
"total_cache",
133
"hierarchical_memory_limit",
134
];
135
const cpu_usage = parseFloat(cpu_raw) / Math.pow(10, 9);
136
const dt = this.dt(timestamp);
137
const cpu_usage_rate =
138
this.last?.cgroup != null
139
? (cpu_usage - this.last.cgroup.cpu_usage) / dt
140
: 0;
141
const [cfs_quota, cfs_period] = [
142
parseInt(cfs_quota_raw),
143
parseInt(cfs_period_raw),
144
];
145
const mem_stat = mem_stat_raw
146
.split("\n")
147
.map((line) => line.split(" "))
148
.filter(([k, _]) => mem_stat_keys.includes(k))
149
.reduce((stat, [key, val]) => {
150
stat[key] = bytes2MiB(parseInt(val));
151
return stat;
152
}, {});
153
const oom_kills = oom_raw
154
.split("\n")
155
.filter((val) => val.startsWith("oom_kill "))
156
.map((val) => parseInt(val.slice("oom_kill ".length)))[0];
157
return {
158
mem_stat,
159
cpu_usage,
160
cpu_usage_rate,
161
cpu_cores_limit: cfs_quota / cfs_period,
162
oom_kills,
163
};
164
} catch (err) {
165
this.dbg("cgroup: error", err);
166
if (err.code == "ENOENT") {
167
// TODO: instead of shutting this down, we could maybe do a better job
168
// figuring out what the correct cgroups files are on a given system.
169
// E.g., in my cocalc-docker, I do NOT have /sys/fs/cgroup/memory/memory.stat
170
// but I do have /sys/fs/cgroup/memory.stat
171
this.cgroupFilesAreMissing = true;
172
this.dbg(
173
"cgroup: files are missing so cgroups info will no longer be updated",
174
);
175
}
176
return undefined;
177
}
178
}
179
180
// for cocalc/kucalc we want to know the disk usage + limits of the
181
// users home dir and /tmp. /tmp is a ram disk, which will count against
182
// the overall memory limit!
183
private async disk_usage(): Promise<DiskUsage> {
184
const convert = function (val: DF_DiskUsage) {
185
return {
186
total: bytes2MiB(val.total),
187
free: bytes2MiB(val.free),
188
available: bytes2MiB(val.available),
189
usage: bytes2MiB(val.total - val.free),
190
};
191
};
192
const [tmp, project] = await Promise.all([
193
df("/tmp"),
194
df(process.env.HOME ?? "/home/user"),
195
]);
196
return { tmp: convert(tmp), project: convert(project) };
197
}
198
199
// orchestrating where all the information is bundled up for an update
200
private async get_info(): Promise<ProjectInfo | undefined> {
201
try {
202
const timestamp = Date.now();
203
const [processes, cgroup, disk_usage] = await Promise.all([
204
this.processes(timestamp),
205
this.cgroup({ timestamp }),
206
this.disk_usage(),
207
]);
208
const { procs, boottime, uptime } = processes;
209
this.lookupCoCalcInfo(procs);
210
const info: ProjectInfo = {
211
timestamp,
212
processes: procs,
213
uptime,
214
boottime,
215
cgroup,
216
disk_usage,
217
};
218
return info;
219
} catch (err) {
220
this.dbg("get_info: error", err);
221
}
222
}
223
224
public stop() {
225
this.running = false;
226
}
227
228
public async start(): Promise<void> {
229
if (this.running) {
230
this.dbg("project-info/server: already running, cannot be started twice");
231
} else {
232
await this._start();
233
}
234
}
235
236
private async _start(): Promise<void> {
237
this.dbg("start");
238
if (this.running) {
239
throw Error("Cannot start ProjectInfoServer twice");
240
}
241
this.running = true;
242
this.processStats = new ProcessStats({
243
testing: this.testing,
244
dbg: this.dbg,
245
});
246
await this.processStats.init();
247
while (true) {
248
//this.dbg(`listeners on 'info': ${this.listenerCount("info")}`);
249
const info = await this.get_info();
250
if (info != null) this.last = info;
251
this.emit("info", info ?? this.last);
252
if (this.running) {
253
await delay(1000 * this.delay_s);
254
} else {
255
this.dbg("start: no longer running → stopping loop");
256
this.last = undefined;
257
return;
258
}
259
// in test mode just one more, that's enough
260
if (this.last != null && this.testing) {
261
const info = await this.get_info();
262
this.dbg(JSON.stringify(info, null, 2));
263
return;
264
}
265
}
266
}
267
}
268
269
// testing: $ ts-node server.ts
270
if (require.main === module) {
271
const pis = new ProjectInfoServer(true);
272
pis.start().then(() => process.exit());
273
}
274
275