UNPKG

@cocalc/project

Version:
360 lines (336 loc) 11.8 kB
/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ /* Project information server, doing the heavy lifting of telling the client about what's going on in a project. */ import debug from "debug"; const L = debug("project:project-info:server"); import { delay } from "awaiting"; import { join } from "path"; import { exec } from "./utils"; import { options } from "../init-program"; import { promises as fsPromises } from "fs"; import { pid2path as terminal_pid2path } from "../terminal/server"; import { get_path_for_pid as x11_pid2path } from "../x11/server"; import { get_kernel_by_pid } from "../jupyter/jupyter"; const { readFile, readdir, readlink } = fsPromises; import { check as df } from "diskusage"; import { EventEmitter } from "events"; import { Cpu, Process, Processes, ProjectInfo, Stat, State, DiskUsage, CoCalcInfo, CGroup, } from "./types"; //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); export class ProjectInfoServer extends EventEmitter { private last?: ProjectInfo = undefined; private readonly dbg: Function; private running = false; private readonly testing: boolean; private ticks: number; private pagesize: number; private delay_s: number; constructor(testing = false) { super(); this.delay_s = 2; this.testing = testing; this.dbg = L; } public latest(): ProjectInfo | undefined { 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 private async uptime(): Promise<[number, Date]> { // 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 private async stat(path: string): Promise<Stat> { // 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] as State, utime: get(13) / this.ticks, // CPU time spent in user code, measured in clock ticks (#14) stime: get(14) / this.ticks, // CPU time spent in kernel code, measured in clock ticks (#15) cutime: get(15) / this.ticks, // Waited-for children's CPU time spent in user code (in clock ticks) (#16) cstime: get(16) / this.ticks, // Waited-for children's CPU time spent in kernel code (in clock ticks) (#17) starttime: get(21) / this.ticks, // Time when the process started, measured in clock ticks (#22) 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 private dt(timestamp) { return (timestamp - (this.last?.timestamp ?? 0)) / 1000; } // calculate cpu times private cpu({ pid, stat, timestamp }): Cpu { // 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 }; } private async cmdline(path: string): Promise<string[]> { // 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 private cocalc({ pid, cmdline }): CoCalcInfo | undefined { //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 = get_kernel_by_pid(pid); if (jupyter_kernel != null) { return { type: "jupyter", path: jupyter_kernel.get_path() }; } const termpath = terminal_pid2path(pid); if (termpath != null) { return { type: "terminal", path: termpath }; } const x11_path = x11_pid2path(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 private async process({ pid: pid_str, uptime, timestamp }): Promise<Process> { const base = join("/proc", pid_str); const pid = parseInt(pid_str); const fn = (name) => 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 private async processes({ timestamp, uptime }): Promise<Processes> { const procs: Processes = {}; 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 private async cgroup({ timestamp }): Promise<CGroup | undefined> { if (!is_in_dev_project() && !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! private async disk_usage(): Promise<DiskUsage> { 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([ df("/tmp"), df(process.env.HOME ?? "/home/user"), ]); return { tmp: convert(tmp), project: convert(project) }; } // this grabs some kernel configuration values we need. they won't change private async init(): Promise<void> { if (this.ticks == null) { const [p_ticks, p_pagesize] = await Promise.all([ exec("getconf CLK_TCK"), 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 private async get_info(): Promise<ProjectInfo> { 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: ProjectInfo = { timestamp, processes, uptime, boottime, cgroup, disk_usage, }; return info; } public stop() { this.running = false; } public async start(): Promise<void> { if (this.running) { this.dbg("project-info/server: already running, cannot be started twice"); } else { await this._start(); } } private async _start(): Promise<void> { 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 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; } } } } // testing: $ ts-node server.ts if (require.main === module) { const pis = new ProjectInfoServer(true); pis.start().then(() => process.exit()); }