UNPKG

@cocalc/backend

Version:

CoCalc backend functionality: functionality used by either the hub, the next.js server or the project.

277 lines 10.8 kB
"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.execute_code = exports.executeCode = void 0; // Execute code in a subprocess. const awaiting_1 = require("awaiting"); const node_child_process_1 = require("node:child_process"); const promises_1 = require("node:fs/promises"); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); const shell_escape_1 = __importDefault(require("shell-escape")); const logger_1 = __importDefault(require("@cocalc/backend/logger")); const aggregate_1 = require("@cocalc/util/aggregate"); const async_utils_1 = require("@cocalc/util/async-utils"); const misc_1 = require("@cocalc/util/misc"); const misc_2 = require("./misc"); const log = (0, logger_1.default)("execute-code"); // Async/await interface to executing code. async function executeCode(opts) { return await (0, async_utils_1.callback_opts)(exports.execute_code)(opts); } exports.executeCode = executeCode; // Callback interface to executing code. // This will get deprecated and is only used by some old coffeescript code. exports.execute_code = (0, aggregate_1.aggregate)((opts) => { (async () => { try { opts.cb?.(undefined, await executeCodeNoAggregate(opts)); } catch (err) { opts.cb?.(err); } })(); }); // actual implementation, without the aggregate wrapper async function executeCodeNoAggregate(opts) { if (opts.args == null) opts.args = []; if (opts.timeout == null) opts.timeout = 10; if (opts.ulimit_timeout == null) opts.ulimit_timeout = true; if (opts.err_on_exit == null) opts.err_on_exit = true; if (opts.verbose == null) opts.verbose = true; if (opts.verbose) { log.debug(`input: ${opts.command} ${opts.args?.join(" ")}`); } const s = opts.command.split(/\s+/g); // split on whitespace if (opts.args?.length === 0 && s.length > 1) { opts.bash = true; } else if (opts.bash && opts.args?.length > 0) { // Selected bash, but still passed in args. opts.command = (0, shell_escape_1.default)([opts.command].concat(opts.args)); opts.args = []; } if (opts.home == null) { opts.home = process.env.HOME; } if (opts.path == null) { opts.path = opts.home; } else if (opts.path[0] !== "/") { opts.path = opts.home + "/" + opts.path; } let tempDir = undefined; try { if (opts.bash) { // using bash, which (for better or worse), we do by writing the command to run // under bash to a file, then executing that file. let cmd; if (opts.timeout && opts.ulimit_timeout) { // This ensures that everything involved with this // command really does die no matter what; it's // better than killing from outside, since it gets // all subprocesses since they inherit the limits. // Leave it to the OS. Note that the argument to ulimit // must be a whole number. cmd = `ulimit -t ${Math.ceil(opts.timeout)}\n${opts.command}`; } else { cmd = opts.command; } // We write the cmd to a file, and replace the command and args // with bash and the filename, then do everything below as we would // have done anyways. opts.command = "bash"; tempDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "cocalc-")); const tempPath = (0, node_path_1.join)(tempDir, "a.sh"); if (opts.verbose) { log.debug("writing temp file that contains bash program", tempPath); } opts.args = [tempPath]; await (0, promises_1.writeFile)(tempPath, cmd); await (0, promises_1.chmod)(tempPath, 0o700); } return await (0, awaiting_1.callback)(doSpawn, opts); } finally { // clean up if (tempDir) { await (0, promises_1.rm)(tempDir, { force: true, recursive: true }); } } } function doSpawn(opts, cb) { const start_time = (0, misc_1.walltime)(); if (opts.verbose) { log.debug("spawning", opts.command, "with args", opts.args, "and timeout", opts.timeout, "seconds"); } const spawnOptions = { detached: true, cwd: opts.path, ...(opts.uid ? { uid: opts.uid } : undefined), ...(opts.gid ? { uid: opts.gid } : undefined), env: { ...(0, misc_2.envForSpawn)(), ...opts.env, ...(opts.uid != null && opts.home ? { HOME: opts.home } : undefined), }, }; let r, ran_code = false; try { r = (0, node_child_process_1.spawn)(opts.command, opts.args, spawnOptions); if (r.stdout == null || r.stderr == null) { // The docs/examples at https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options // suggest that r.stdout and r.stderr are always defined. However, this is // definitely NOT the case in edge cases, as we have observed. cb("error creating child process -- couldn't spawn child process"); return; } } catch (error) { // Yes, spawn can cause this error if there is no memory, and there's no // event! -- Error: spawn ENOMEM ran_code = false; cb(`error ${error}`); return; } ran_code = true; if (opts.verbose) { log.debug("listening for stdout, stderr and exit_code..."); } let stdout = ""; let stderr = ""; let exit_code = undefined; r.stdout.on("data", (data) => { data = data.toString(); if (opts.max_output != null) { if (stdout.length < opts.max_output) { stdout += data.slice(0, opts.max_output - stdout.length); } } else { stdout += data; } }); r.stderr.on("data", (data) => { data = data.toString(); if (opts.max_output != null) { if (stderr.length < opts.max_output) { stderr += data.slice(0, opts.max_output - stderr.length); } } else { stderr += data; } }); let stderr_is_done = false; let stdout_is_done = false; let killed = false; r.stderr.on("end", () => { stderr_is_done = true; finish(); }); r.stdout.on("end", () => { stdout_is_done = true; finish(); }); r.on("exit", (code) => { exit_code = code; finish(); }); // This can happen, e.g., "Error: spawn ENOMEM" if there is no memory. Without this handler, // an unhandled exception gets raised, which is nasty. // From docs: "Note that the exit-event may or may not fire after an error has occurred. " r.on("error", (err) => { if (exit_code == null) { exit_code = 1; } stderr += (0, misc_1.to_json)(err); // a fundamental issue, we were not running some code ran_code = false; finish(); }); let callback_done = false; const finish = (err) => { if (!killed && (!stdout_is_done || !stderr_is_done || exit_code == null)) { // it wasn't killed and one of stdout, stderr, and exit_code hasn't been // set, so we let the rest of them get set before actually finishing up. return; } if (callback_done) { // we already finished up. return; } // finally finish up. callback_done = true; if (timer != null) { clearTimeout(timer); timer = undefined; } if (opts.verbose && log.isEnabled("debug")) { log.debug("finished exec of", opts.command, "took", (0, misc_1.walltime)(start_time), "seconds"); log.debug("stdout=", (0, misc_1.trunc)(stdout, 512), "stderr=", (0, misc_1.trunc)(stderr, 512), "exit_code=", exit_code); } if (err) { cb(err); } else if (opts.err_on_exit && exit_code != 0) { cb(`command '${opts.command}' (args=${opts.args?.join(" ")}) exited with nonzero code ${exit_code} -- stderr='${(0, misc_1.trunc)(stderr, 1024)}'`); } else if (!ran_code) { // regardless of opts.err_on_exit ! cb(`command '${opts.command}' (args=${opts.args?.join(" ")}) was not able to run -- stderr='${(0, misc_1.trunc)(stderr, 1024)}'`); } else { if (opts.max_output != null) { if (stdout.length >= opts.max_output) { stdout += ` (truncated at ${opts.max_output} characters)`; } if (stderr.length >= opts.max_output) { stderr += ` (truncated at ${opts.max_output} characters)`; } } if (exit_code == null) { // if exit-code not set, may have been SIGKILL so we set it to 1 exit_code = 1; } cb(undefined, { stdout, stderr, exit_code }); } }; let timer = undefined; if (opts.timeout) { // setup a timer that will kill the command after a certain amount of time. const f = () => { if (r.exitCode != null) { // command already exited. return; } if (opts.verbose) { log.debug("subprocess did not exit after", opts.timeout, "seconds, so killing with SIGKILL"); } try { killed = true; process.kill(-r.pid, "SIGKILL"); // this should kill process group } catch (err) { // Exceptions can happen, which left uncaught messes up calling code big time. if (opts.verbose) { log.debug("r.kill raised an exception", err); } } finish(`killed command '${opts.command} ${opts.args?.join(" ")}'`); }; timer = setTimeout(f, opts.timeout * 1000); } } //# sourceMappingURL=execute-code.js.map