@cocalc/project
Version:
CoCalc: project daemon
325 lines • 12.5 kB
JavaScript
"use strict";
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProjectInfoServer = void 0;
/*
Project information server, doing the heavy lifting of telling the client
about what's going on in a project.
*/
const debug_1 = __importDefault(require("debug"));
const L = (0, debug_1.default)("project:project-info:server");
const awaiting_1 = require("awaiting");
const path_1 = require("path");
const utils_1 = require("./utils");
const init_program_1 = require("../init-program");
const fs_1 = require("fs");
const server_1 = require("../terminal/server");
const server_2 = require("../x11/server");
const jupyter_1 = require("../jupyter/jupyter");
const { readFile, readdir, readlink } = fs_1.promises;
const diskusage_1 = require("diskusage");
const events_1 = require("events");
//const { get_sage_path } = require("../sage_session");
function is_in_dev_project() {
return process.env.SMC_LOCAL_HUB_HOME != null;
}
// this is a hard limit on the number of processes we gather, just to
// be on the safe side to avoid processing too much data.
const LIMIT = 100;
const bytes2MiB = (bytes) => bytes / (1024 * 1024);
class ProjectInfoServer extends events_1.EventEmitter {
constructor(testing = false) {
super();
this.last = undefined;
this.running = false;
this.delay_s = 2;
this.testing = testing;
this.dbg = L;
}
latest() {
return this.last;
}
// this is how long the underlying machine is running
// we need this information, because the processes' start time is
// measured in "ticks" since the machine started
async uptime() {
// return uptime in secs
const out = await readFile("/proc/uptime", "utf8");
const uptime = parseFloat(out.split(" ")[0]);
const boottime = new Date(new Date().getTime() - 1000 * uptime);
return [uptime, boottime];
}
// the "stat" file contains all the information
// this page explains what is what
// https://man7.org/linux/man-pages/man5/proc.5.html
async stat(path) {
// all time-values are in seconds
const raw = await readFile(path, "utf8");
// the "comm" field could contain additional spaces or parents
const [i, j] = [raw.indexOf("("), raw.lastIndexOf(")")];
const start = raw.slice(0, i - 1).trim();
const end = raw.slice(j + 1).trim();
const data = `${start} comm ${end}`.split(" ");
const get = (idx) => parseInt(data[idx]);
// "comm" is now a placeholder to keep indices as they are.
// don't forget to account for 0 vs. 1 based indexing.
const ret = {
ppid: get(3),
state: data[2],
utime: get(13) / this.ticks,
stime: get(14) / this.ticks,
cutime: get(15) / this.ticks,
cstime: get(16) / this.ticks,
starttime: get(21) / this.ticks,
nice: get(18),
num_threads: get(19),
mem: { rss: (get(23) * this.pagesize) / (1024 * 1024) }, // MiB
};
return ret;
}
// delta-time for this and the previous process information
dt(timestamp) {
return (timestamp - (this.last?.timestamp ?? 0)) / 1000;
}
// calculate cpu times
cpu({ pid, stat, timestamp }) {
// we are interested in that processes total usage: user + system
const total_cpu = stat.utime + stat.stime;
// the fallback is chosen in such a way, that it says 0% if we do not have historic data
const prev_cpu = this.last?.processes?.[pid]?.cpu.secs ?? total_cpu;
const dt = this.dt(timestamp);
// how much cpu time was used since last time we checked this process…
const pct = 100 * ((total_cpu - prev_cpu) / dt);
return { pct: pct, secs: total_cpu };
}
async cmdline(path) {
// we split at the null-delimiter and filter all empty elements
return (await readFile(path, "utf8"))
.split("\0")
.filter((c) => c.length > 0);
}
// for a process we know (pid, etc.) we try to map to cocalc specific information
cocalc({ pid, cmdline }) {
//this.dbg("classify", { pid, exe, cmdline });
if (pid === process.pid) {
return { type: "project" };
}
// TODO use get_sage_path to get a path to a sagews
const jupyter_kernel = (0, jupyter_1.get_kernel_by_pid)(pid);
if (jupyter_kernel != null) {
return { type: "jupyter", path: jupyter_kernel.get_path() };
}
const termpath = (0, server_1.pid2path)(pid);
if (termpath != null) {
return { type: "terminal", path: termpath };
}
const x11_path = (0, server_2.get_path_for_pid)(pid);
if (x11_path != null) {
return { type: "x11", path: x11_path };
}
// SSHD: strangely, just one long string in cmdline[0]
if (cmdline.length === 1 &&
cmdline[0].startsWith("sshd:") &&
cmdline[0].indexOf("-p 2222") != -1) {
return { type: "sshd" };
}
}
// this gathers all the information for a specific process with the given pid
async process({ pid: pid_str, uptime, timestamp }) {
const base = (0, path_1.join)("/proc", pid_str);
const pid = parseInt(pid_str);
const fn = (name) => (0, path_1.join)(base, name);
const [cmdline, exe, stat] = await Promise.all([
this.cmdline(fn("cmdline")),
readlink(fn("exe")),
this.stat(fn("stat")),
]);
const data = {
pid,
ppid: stat.ppid,
cmdline,
exe,
stat,
cpu: this.cpu({ pid, timestamp, stat }),
uptime: uptime - stat.starttime,
cocalc: this.cocalc({ pid, cmdline }),
};
return data;
}
// this is where we gather information about all running processes
async processes({ timestamp, uptime }) {
const procs = {};
let n = 0;
for (const pid of await readdir("/proc")) {
if (!pid.match(/^[0-9]+$/))
continue;
try {
const proc = await this.process({ pid, uptime, timestamp });
procs[proc.pid] = proc;
}
catch (err) {
if (this.testing)
this.dbg(`process ${pid} likely vanished – could happen – ${err}`);
}
// we avoid processing and sending too much data
if (n > LIMIT) {
this.dbg(`too many processes – limit of ${LIMIT} reached!`);
break;
}
else {
n += 1;
}
}
return procs;
}
// this is specific to running a project in a CGroup container
// however, even without a container this shouldn't fail … just tells
// you what the whole system is doing, all your processes,…
// NOTE: most of this replaces kucalc.coffee
async cgroup({ timestamp }) {
if (!is_in_dev_project() && !init_program_1.options.kucalc && !this.testing)
return;
const [mem_stat_raw, cpu_raw, oom_raw, cfs_quota_raw, cfs_period_raw,] = await Promise.all([
readFile("/sys/fs/cgroup/memory/memory.stat", "utf8"),
readFile("/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage", "utf8"),
readFile("/sys/fs/cgroup/memory/memory.oom_control", "utf8"),
readFile("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us", "utf8"),
readFile("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us", "utf8"),
]);
const mem_stat_keys = [
"total_rss",
"total_cache",
"hierarchical_memory_limit",
];
const cpu_usage = parseFloat(cpu_raw) / Math.pow(10, 9);
const dt = this.dt(timestamp);
const cpu_usage_rate = this.last?.cgroup != null
? (cpu_usage - this.last.cgroup.cpu_usage) / dt
: 0;
const [cfs_quota, cfs_period] = [
parseInt(cfs_quota_raw),
parseInt(cfs_period_raw),
];
const mem_stat = mem_stat_raw
.split("\n")
.map((line) => line.split(" "))
.filter(([k, _]) => mem_stat_keys.includes(k))
.reduce((stat, [key, val]) => {
stat[key] = bytes2MiB(parseInt(val));
return stat;
}, {});
const oom_kills = oom_raw
.split("\n")
.filter((val) => val.startsWith("oom_kill "))
.map((val) => parseInt(val.slice("oom_kill ".length)))[0];
return {
mem_stat,
cpu_usage,
cpu_usage_rate,
cpu_cores_limit: cfs_quota / cfs_period,
oom_kills,
};
}
// for cocalc/kucalc we want to know the disk usage + limits of the
// users home dir and /tmp. /tmp is a ram disk, which will count against
// the overall memory limit!
async disk_usage() {
const convert = function (val) {
return {
total: bytes2MiB(val.total),
free: bytes2MiB(val.free),
available: bytes2MiB(val.available),
usage: bytes2MiB(val.total - val.free),
};
};
const [tmp, project] = await Promise.all([
(0, diskusage_1.check)("/tmp"),
(0, diskusage_1.check)(process.env.HOME ?? "/home/user"),
]);
return { tmp: convert(tmp), project: convert(project) };
}
// this grabs some kernel configuration values we need. they won't change
async init() {
if (this.ticks == null) {
const [p_ticks, p_pagesize] = await Promise.all([
(0, utils_1.exec)("getconf CLK_TCK"),
(0, utils_1.exec)("getconf PAGESIZE"),
]);
// should be 100, usually
this.ticks = parseInt(p_ticks.stdout.trim());
// 4096?
this.pagesize = parseInt(p_pagesize.stdout.trim());
}
}
// orchestrating where all the information is bundled up for an update
async get_info() {
const [uptime, boottime] = await this.uptime();
const timestamp = new Date().getTime();
const [processes, cgroup, disk_usage] = await Promise.all([
this.processes({ uptime, timestamp }),
this.cgroup({ timestamp }),
this.disk_usage(),
]);
const info = {
timestamp,
processes,
uptime,
boottime,
cgroup,
disk_usage,
};
return info;
}
stop() {
this.running = false;
}
async start() {
if (this.running) {
this.dbg("project-info/server: already running, cannot be started twice");
}
else {
await this._start();
}
}
async _start() {
this.dbg("start");
if (this.running) {
throw Error("Cannot start ProjectInfoServer twice");
}
this.running = true;
await this.init();
while (true) {
//this.dbg(`listeners on 'info': ${this.listenerCount("info")}`);
const info = await this.get_info();
this.last = info;
this.emit("info", info);
if (this.running) {
await (0, awaiting_1.delay)(1000 * this.delay_s);
}
else {
this.dbg("start: no longer running → stopping loop");
this.last = undefined;
return;
}
// in test mode just one more, that's enough
if (this.last != null && this.testing) {
const info = await this.get_info();
this.dbg(JSON.stringify(info, null, 2));
return;
}
}
}
}
exports.ProjectInfoServer = ProjectInfoServer;
// testing: $ ts-node server.ts
if (require.main === module) {
const pis = new ProjectInfoServer(true);
pis.start().then(() => process.exit());
}
//# sourceMappingURL=server.js.map