@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,520 lines ⢠63.5 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { UNKNOWN_NAME } from "../../commands/builtin/utils/identity.js";
import { gnuStrftime } from "../../commands/builtin/utils/strftime.js";
import { currentCaller, isProgramInvocation, runWithCaller, } from "../../context/session_context.js";
import { CommandTimeoutError } from "../../errors/types.js";
import { holdInputs } from "../../io/async_line_iterator.js";
import { PipeClosed } from "../../io/errors.js";
import { IOResult } from "../../io/types.js";
import { LINE_NAME } from "../../policy/builtin/output_cap.js";
import { isPending } from "../../policy/decisions.js";
import { encodeText } from "../../shell/bytes.js";
import { CallStack } from "../../shell/call_stack.js";
import { Channel, JobConsole, JobOutput, Tee } from "../../shell/console/index.js";
import { PipeConsole } from "../../shell/console/pipe.js";
import { FD_BOTH, FD_CLOSE, FORK_FAILED, FORK_FAILED_STATUS, NOT_FOUND_STATUS, TIMEOUT_STATUS, } from "../../shell/constants.js";
import { ExitSignal, ReturnSignal } from "../../shell/errors.js";
import { getRedirects, isBackgrounded } from "../../shell/helpers.js";
import { STOP_WAIT_SECONDS } from "../../shell/job_table/constants.js";
import { JobStatus } from "../../shell/job_table/index.js";
import { NodeKind, nodeKind, simpleCommand } from "../../shell/node_kind.js";
import { commandInvocations } from "../../shell/parse/names.js";
import { retainPrograms } from "../../shell/parse/program.js";
import { PathSpec } from "../../types.js";
import { abortable, atDeadline } from "../../utils/abort.js";
import { asyncContextIsolatesTasks } from "../../utils/async_context.js";
import { systemNow } from "../../utils/clock.js";
import { LOCAL_ZONE, zoneFromEnv } from "../../utils/timezone.js";
import { childContext } from "../evaluation.js";
import { ExecutionScope } from "../execution.js";
import { CAPTURABLE_BUILTINS, SHELL_NAMES } from "../lookup/constants.js";
import { occurrenceOf } from "../node/occurrence.js";
import { ExecutionNode } from "../types.js";
import { scanOptions } from "./builtins/getopt.js";
import { builtinUsage } from "./builtins/shared.js";
import { answerClosed, failedRead, statementStdin } from "./statement.js";
import { inheritTraps } from "./traps.js";
/**
* Send a command's output to a console as chunks arrive.
*
* Consuming the stream piece by piece rather than materializing it whole
* is what lets a reader watch a running job. A command that computes its
* output eagerly still lands in one chunk, because there was nothing to
* observe before it finished. A pipe is drained before the next chunk is
* pulled, so a reader that closed stops the source before it fetches more.
*/
export async function pump(console_, channel, stream) {
if (stream === null)
return;
if (stream instanceof Uint8Array) {
if (stream.byteLength > 0)
await console_.emit(channel, stream);
return;
}
for await (const chunk of stream) {
if (chunk.byteLength > 0)
await console_.emit(channel, chunk);
if (!(console_ instanceof PipeConsole))
continue;
await console_.drain();
if (console_.closedReader)
return;
}
}
/**
* Write a finished statement's returned output to a sink, its stdout before
* its stderr, since one command keeps no order between them; what it already
* wrote there as it ran (a function body, a redirected group) came first. A
* read its stream fails is the statement's own failure (`failedRead`). The
* result carries no output, so nothing lands twice. Mirrors Python's drained.
*/
export async function drained(sink, stdout, io, execNode) {
try {
await pump(sink, Channel.STDOUT, stdout);
}
catch (err) {
if (err instanceof PipeClosed) {
// SIGPIPE keeps what the writer already said on stderr (`cat /missing
// big > >(head -c 1)`), unless it answers itself (`tee -p`).
await sendStderr(sink, io);
if (!(await answerClosed(io, (channel, data) => sink.emit(channel, data))))
throw err;
execNode.exitCode = io.exitCode;
}
else
await failedRead(io, err, execNode);
}
if (io.output !== null)
execNode.exitCode = io.exitCode;
await sendStderr(sink, io);
return [null, io, execNode];
}
/** Write a statement's stderr to its sink, emptying it in the result. Mirrors Python's _send_stderr. */
async function sendStderr(sink, io) {
const stderr = await io.materializeStderr();
if (stderr.byteLength > 0) {
await sink.emit(Channel.STDERR, stderr);
io.stderr = null;
}
}
/**
* Where a job's output goes on from its own console, given up once the
* job is killed. A promise cannot be cancelled, so a write that a stalled
* reader holds, or that waits for a reader to take what waited for it,
* would keep a killed job's runner (and its process slot) waiting; each
* write races the job's signal instead. Python's cancelled task unwinds
* at that await on its own.
*/
class JobCopy extends JobConsole {
target;
signal;
constructor(target, signal) {
super();
this.target = target;
this.signal = signal;
}
async emit(channel, data) {
await abortable(this.target.emit(channel, data), this.signal);
}
async emitTo(stream, data) {
await abortable(this.target.emitTo(stream, data), this.signal);
}
}
/**
* The streams a job started from `node` writes, as its shell hands them
* on: stdout, stderr and the copies the shell holds (`3>&1`), after the
* job's own redirects (`sleep 9 >/dev/null &`). A stream sent to a file
* or closed is gone.
*/
function jobStreams(node, session) {
const fds = new Map([
[1, Channel.STDOUT],
[2, Channel.STDERR],
]);
for (const [fd, descriptor] of session.descriptors)
if (fd > 2)
fds.set(fd, descriptor.stream ?? null);
if (nodeKind(node) === NodeKind.REDIRECT)
for (const r of getRedirects(node)[1]) {
if (r.target === FD_CLOSE)
fds.delete(r.fd);
else if (typeof r.target === 'number')
fds.set(r.fd, fds.get(r.target) ?? null);
else
for (const fd of r.fd === FD_BOTH ? [1, 2] : [r.fd])
fds.set(fd, null);
}
const streams = new Set();
for (const stream of fds.values())
if (stream !== null)
streams.add(stream);
return streams;
}
/**
* Whether a job is one program, as bash runs `cmd &` as the program
* itself: a simple command whose word names neither a function nor a shell
* builtin a runtime cannot take (`eval`, `source`), either of which keeps a
* shell between the job and the program, and which the signal ends. `exec`
* and `command` run the word after their options in place, so that word is
* the one judged; `command -v` only looks it up. A word an expansion
* spells, or one still quoting a character (`e\val`), is not known before
* it runs, and words that run commands of their own (`$( )`) run them as
* the job, so neither is one. Mirrors Python's `_one_program`.
*/
function oneProgram(left, session) {
if (!simpleCommand(left))
return false;
const invocations = commandInvocations(left);
const first = invocations[0];
if (invocations.length !== 1 || first === undefined)
return false;
let words = [first[0], ...first[1]];
while (words[0] === 'exec' || words[0] === 'command') {
const wrapper = words[0];
words = words.slice(1);
while (words[0]?.startsWith('-') === true) {
if (wrapper === 'command' && /[vV]/.test(words[0]))
return false;
words = words.slice(words[0] === '-a' ? 2 : 1);
}
}
const head = words[0] ?? null;
return (head !== null &&
!head.includes('\\') &&
(!SHELL_NAMES.has(head) || CAPTURABLE_BUILTINS.has(head)) &&
!Object.hasOwn(session.functions, head));
}
export async function handleBackground(executeNode, left, right, context, jobTable, stdin = null, callStack = null,
// The line's hand-off and the ledger it lives in. The claims the
// line's pass made for the commands inside the job leave that
// hand-off for one of the job's own before the job starts
// (`Decisions.split`): its gates run after the line has returned, and
// its grants have to stay reserved through the line's end whichever
// way the line ends, a release for a question left waiting included.
// The job's whole subtree runs on that hand-off, the lines it
// evaluates included (the walker binds it into their entry point), and the
// job revokes it when it ends.
handed = null, decisions = null) {
const session = context.session;
const releaseProgram = retainPrograms([left]);
const childEvaluation = childContext(context);
const bgSession = childEvaluation.session;
const heldInputs = holdInputs(bgSession.openInputs());
inheritTraps(bgSession);
const output = session.jobOutput ?? session.tty.jobs;
// A job is a shell of its own: what jobs it starts write into the
// statement it runs, then where it writes.
bgSession.jobOutput = new JobOutput(output);
// A job is a child shell outside every loop: `{ break; } &` in a loop
// refuses, as bash's does.
const bgCallStack = (callStack ?? new CallStack()).fork(false, simpleCommand(left) ? null : true);
const jobHanded = handed !== null && decisions !== null
? decisions.split(session.sessionId, handed, occurrenceOf(left, handed))
: null;
const abort = new AbortController();
// `kill %n` aborts this controller; the signal rides the forked
// session so the job's whole subtree (builtins, mounts, runtimes)
// observes the kill, merged with any enclosing job's channel. An
// enclosing job's limit is not part of that channel: it aborts with a
// CommandTimeoutError, which this job does not answer to, so a job runs
// to its own limit as a line's deadline leaves the line's jobs alone.
// The job stops listening once it ends or is killed, so a long-lived
// job keeps nothing of the jobs it started.
const enclosing = context.frame.abortSignal;
const inherited = new AbortController();
const follow = () => {
if (!(enclosing?.reason instanceof CommandTimeoutError))
inherited.abort(enclosing?.reason);
};
const unfollow = () => {
enclosing?.removeEventListener('abort', follow);
};
if (enclosing?.aborted === true)
follow();
else
enclosing?.addEventListener('abort', follow, { once: true, signal: abort.signal });
const killed = AbortSignal.any([inherited.signal, abort.signal]);
// The job runs under the `@line` limit of the line that started it, from
// its own start, its output included: past it the subtree is stopped as a
// kill stops it, a write a reader holds included, and the job answers 124
// and the line's message, as a line does.
const expiry = new AbortController();
const stopped = AbortSignal.any([killed, expiry.signal]);
childEvaluation.frame.abortSignal = stopped;
const cmdStrInner = left.text;
const runBg = async (job) => {
// What the job writes stays in its console and goes where its shell
// writes as it is written: the terminal, or the substitution or pipe
// it was started in.
const console_ = new Tee(job.console, new JobCopy(output, stopped));
const hold = { held: false, ended: false };
const lineSink = currentCaller().refusals;
// The line still hears of every refusal; a question left waiting also
// holds the job.
const note = (refusal) => {
hold.held ||= isPending(refusal);
lineSink?.(refusal);
};
const run = async () => {
let stdout;
let io;
let execNode;
try {
// The sink is what makes compound bodies stream: each statement
// writes as it finishes rather than the whole construct landing
// at the end. The signal is what makes `kill` able to stop the
// job at all, since a promise cannot be cancelled.
const opts = {
sink: console_,
signal: AbortSignal.any([abort.signal, expiry.signal]),
executionScope: new ExecutionScope(undefined, job.executionId ?? undefined),
endsShell: true,
jobTable: jobTable.child(),
};
if (jobHanded !== null)
opts.handed = jobHanded;
[stdout, io, execNode] = await executeNode(left, childEvaluation, null, bgCallStack, opts);
}
catch (err) {
if (err instanceof ExitSignal) {
// A background job is its own shell: exit ends the job only, a
// simple command the top shell forked with the status that shell
// would exit with (`: ${U?} &` is 127, `{ : ${U?}; } &` and
// `( : ${U?} & )` are 1).
stdout = err.stdout ?? new Uint8Array();
const status = simpleCommand(left) && !err.sourced && callStack?.subshell !== true
? err.exitCode
: err.containedCode;
io = new IOResult({ exitCode: status, stderr: err.stderr });
execNode = new ExecutionNode({
command: cmdStrInner,
stderr: err.stderr,
exitCode: status,
});
}
else if (err instanceof ReturnSignal) {
stdout = err.stdout;
io = new IOResult({ exitCode: err.exitCode, stderr: err.stderr });
execNode = new ExecutionNode({
command: cmdStrInner,
stderr: err.stderr,
exitCode: err.exitCode,
});
}
else {
throw err;
}
}
// Drained inside the rebind: pumping the stream can still run
// ops that read the ambient session.
await pump(console_, Channel.STDOUT, stdout);
const stderr = await io.materializeStderr();
if (stderr.byteLength > 0) {
await console_.emit(Channel.STDERR, stderr);
}
return [io, execNode];
};
const body = async () => {
const seconds = childEvaluation.frame.lineTimeout;
const disarm = seconds !== null && seconds > 0
? atDeadline(seconds, () => {
expiry.abort(new CommandTimeoutError(LINE_NAME, seconds));
})
: undefined;
let result;
try {
result = await run();
}
catch (err) {
// Past its own limit, or stopped by a command's own timeout, the job
// answers as a line does, 124 and the message; a killed job ends as
// killed.
const timedOut = seconds !== null && expiry.signal.aborted
? new CommandTimeoutError(LINE_NAME, seconds)
: err instanceof CommandTimeoutError
? err
: null;
if (timedOut === null || abort.signal.aborted)
throw err;
const msg = encodeText(`${timedOut.message}\n`);
// The job's own console keeps the notice; the shell's output gets it
// unless a reader that stopped reading holds it past the wait a
// stopped job is given, so the job still ends.
await job.console.emit(Channel.STDERR, msg);
try {
await abortable(output.emit(Channel.STDERR, msg), AbortSignal.any([killed, AbortSignal.timeout(STOP_WAIT_SECONDS * 1000)]));
}
catch (err) {
console.debug('job: its timeout notice found no reader', err);
}
result = [
new IOResult({ exitCode: TIMEOUT_STATUS }),
new ExecutionNode({ command: cmdStrInner, stderr: msg, exitCode: TIMEOUT_STATUS }),
];
}
finally {
disarm?.();
}
hold.ended = hold.held || isPending(result[0].refusal);
return result;
};
// The job's own layer of the caller: its fork, so dispatcher and host
// callbacks stay in it; its session writes, calls of the job's spent
// at its end, not of the line it outlives; and a sink that tells the
// line of every refusal. Without task isolation a frame the job bound
// would reach every other task, after the line ended too, so there it
// binds none; nested shell evaluations carry the walker's exact session
// explicitly on both runtimes.
const outer = currentCaller();
const own = {
...outer,
session: childEvaluation.session,
evaluation: childEvaluation,
call: jobHanded !== null && decisions !== null
? [decisions, jobHanded, session.sessionId]
: outer.call,
refusals: lineSink !== null ? note : null,
};
try {
return await (asyncContextIsolatesTasks ? runWithCaller(own, body) : body());
}
finally {
unfollow();
releaseProgram();
await bgSession.closeInputs();
for (const source of heldInputs)
await source.lines.unhold();
if (jobHanded !== null && decisions !== null) {
// A job that ended held on a question leaves its grants standing
// for the retry, as a held line does; a killed or failed one spends
// them.
if (hold.ended)
decisions.release(session.sessionId, jobHanded);
else
await decisions.revoke(session.sessionId, jobHanded);
}
}
};
const cmdStr = left.text;
// Non-interactive bash announces nothing on launch ("[1] <pid>" is
// interactive-only); the job stays discoverable via $! and `jobs`.
let job;
try {
job = await jobTable.submit({
command: cmdStr,
run: runBg,
abort,
cwd: bgSession.cwd,
sessionId: session.sessionId,
parentPid: session.processId,
limit: session.processes.max,
simple: oneProgram(left, session),
});
}
catch (err) {
unfollow();
releaseProgram();
for (const source of heldInputs)
await source.lines.unhold();
// A submission that fails (a console the table cannot build, a
// session at its process cap) starts no runner, so nothing would ever
// revoke the job's hand-off: its grants would stay reserved for good,
// neither spent nor on offer to any later line.
if (jobHanded !== null && decisions !== null) {
await decisions.revoke(session.sessionId, jobHanded);
}
if (err.code === 'EAGAIN')
throw new ExitSignal(FORK_FAILED_STATUS, encodeText(FORK_FAILED));
throw err;
}
bgSession.processId = job.process?.info.pid ?? null;
session.lastBgJobId = job.pid;
if (session.jobWaits?.reaches(output, jobStreams(left, session)) === true)
session.jobWaits.add(job);
if (right === null) {
const tree = new ExecutionNode({
op: '&',
exitCode: 0,
children: [new ExecutionNode({ command: cmdStr, exitCode: 0 })],
});
return [null, new IOResult(), tree];
}
const [rightStdout, rightIo, rightExec] = await executeNode(right, context, stdin, callStack);
const children = [new ExecutionNode({ command: cmdStr, exitCode: 0 }), rightExec];
const tree = new ExecutionNode({
op: '&',
exitCode: rightIo.exitCode,
children,
});
return [rightStdout, rightIo, tree];
}
/**
* Run one statement of a compound body, as a job when it ends in `&`.
*
* The program loop and the subshell body read the `&` off the token
* stream themselves; a loop body, an if/case arm, a brace group or a
* function body holds named nodes only, so the statement is asked about
* its own terminator. The launch is a statement in its own right and
* answers with status 0, as in bash, so `false &` inside a body trips
* neither `$?` nor `set -e`. A null `jobTable` means the caller wired no
* job plane, which is a programming error once a `&` shows up, not a
* reason to run it inline. `bound` is `fd0Binding` as the body started,
* so an `exec <` in it replaces `stdin` for the statements after it; a
* job still gets the body's own stdin.
*/
export function runStatement(executeNode, node, context, stdin, bound, callStack, jobTable, handed = null, decisions = null) {
const session = context.session;
if (!isBackgrounded(node)) {
return executeNode(node, context, statementStdin(session, stdin, bound), callStack);
}
if (jobTable === null) {
throw new Error(`\`${node.text} &\` needs a job table; none was wired`);
}
return handleBackground(executeNode, node, null, context, jobTable, stdin, callStack, handed, decisions);
}
const JOB_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
function jobResult(cmdStr, msg, code) {
const err = encodeText(msg);
return [
null,
new IOResult({ exitCode: code, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: code, stderr: err }),
];
}
/**
* The job list a builtin reads: the calling session's, or the shared
* empty id when it runs with no session (a bare table in a test).
*/
function sessionOf(session) {
return session?.sessionId ?? '';
}
/** The managed runners `ps` and numeric `kill` reach, scoped by the session's profile. */
function processView(jobTable, session) {
return session === null
? jobTable.processes.view('')
: jobTable.processes.view(session.sessionId, () => session.processes);
}
/** The job whose number is `jobId`, the one `%N` names. */
function jobNumbered(jobs, jobId) {
return jobs.find((j) => j.id === jobId) ?? null;
}
/**
* The job a bare PID names. A job whose runner is here answers by its
* runner's PID. One with no runner here, restored or another workspace's,
* answers by the job number standing in for one, but only where no runner
* here holds that PID: another workspace numbers its jobs on the shared
* list, so its `%2` can share a number with a PID handed out here.
*/
function jobWithPid(jobs, pid) {
return (jobs.find((j) => j.process !== null && j.pid === pid) ??
jobs.find((j) => j.process === null && j.pid === pid) ??
null);
}
/**
* The job a `wait`/`disown` operand names, or bash's refusal. A `%N`
* spec naming no job is `no such job`; a bare number is a managed PID,
* also returned by `$!`, so an unknown one is `pid N is not a child of
* this shell`. Anything else is `not a pid or valid job spec`.
*/
function resolveSpec(jobs, spec) {
if (spec.startsWith('%')) {
const raw = spec.slice(1);
const job = /^[0-9]+$/.test(raw) ? jobNumbered(jobs, Number(raw)) : null;
return [job, job !== null ? '' : `${spec}: no such job`];
}
if (/^[0-9]+$/.test(spec)) {
const job = jobWithPid(jobs, Number(spec));
return [job, job !== null ? '' : `pid ${spec} is not a child of this shell`];
}
return [null, `\`${spec}': not a pid or valid job spec`];
}
/**
* Block until the first of several jobs ends, and return it. Once one has,
* the other waits are abandoned, as `signal` abandons them all.
*/
async function waitFirst(jobTable, jobs, signal) {
for (const job of jobs) {
if (job.status !== JobStatus.RUNNING)
return await jobTable.wait(job.id, job.sessionId, signal);
}
const losers = new AbortController();
const done = signal === undefined ? losers.signal : AbortSignal.any([signal, losers.signal]);
try {
return await Promise.race(jobs.map((job) => jobTable.wait(job.id, job.sessionId, done)));
}
finally {
losers.abort();
}
}
/**
* Report one finished job's status, and reap it. Its output already went
* where its shell writes as it was written.
*/
async function reaped(jobTable, job, cmdStr) {
// Reaped like GNU bash reaps a job waited on by id, so a later bare
// `wait` does not answer for it again.
await jobTable.reap(job.id, job.sessionId);
return [
null,
new IOResult({ exitCode: job.exitCode }),
new ExecutionNode({ command: cmdStr, exitCode: job.exitCode }),
];
}
/**
* Wait for background jobs, with bash's option surface. A job's output
* went where its shell writes as it was written, so `wait` prints none,
* as bash's does. Bare `wait` joins every job; `wait ID...` joins those
* and answers the last one's status; `-n` joins the first of the given jobs
* (or of all) to finish, 127 when there is nothing to wait for; `-p VAR`
* stores the id of the job whose status is answered, unsetting VAR when
* none is (which is the bare form, since it reports no one job); `-f` is
* accepted, since a mirage job cannot stop, only end.
*
* `-p` stores the managed PID, matching `$!` and `jobs -p`.
*/
export async function handleWait(jobTable, parts, session = null, view = null, signal) {
const cmdStr = parts.join(' ');
const sid = sessionOf(session);
let nextJob = false;
let varName = null;
const specs = [];
let i = 1;
while (i < parts.length) {
const word = parts[i] ?? '';
if (specs.length > 0 || !word.startsWith('-') || word === '-') {
specs.push(word);
i++;
continue;
}
if (word === '--') {
for (const item of parts.slice(i + 1))
specs.push(item);
break;
}
let j = 1;
let bad = null;
while (j < word.length) {
const ch = word[j] ?? '';
if (ch === 'n')
nextJob = true;
else if (ch === 'f') {
// A mirage job cannot stop, only end, so `-f` is already true.
}
else if (ch === 'p') {
const rest = word.slice(j + 1);
if (rest !== '')
varName = rest;
else if (i + 1 < parts.length) {
i++;
varName = parts[i] ?? '';
}
else {
return jobResult(cmdStr, `bash: wait: -p: option requires an argument\n${builtinUsage('wait')}`, 2);
}
break;
}
else {
bad = ch;
break;
}
j++;
}
if (bad !== null) {
return jobResult(cmdStr, `bash: wait: -${bad}: invalid option\n${builtinUsage('wait')}`, 2);
}
i++;
}
if (varName !== null) {
if (!JOB_IDENTIFIER.test(varName)) {
return jobResult(cmdStr, `bash: wait: \`${varName}': not a valid identifier\n`, 1);
}
if (view?.isReadonly(varName) === true) {
return jobResult(cmdStr, `bash: wait: ${varName}: cannot unset: readonly variable\n`, 1);
}
if (view !== null)
await view.unset(varName);
}
const errors = [];
const picked = [];
await jobTable.sync(sid);
const visible = jobTable.listJobs(sid);
for (const spec of specs) {
const [job, refusal] = resolveSpec(visible, spec);
if (job === null) {
errors.push(`bash: wait: ${refusal}`);
continue;
}
picked.push(job);
}
const errText = errors.length > 0 ? errors.join('\n') + '\n' : '';
const errBytes = errText !== '' ? encodeText(errText) : null;
if (nextJob) {
const candidates = specs.length > 0 ? picked : visible;
if (candidates.length === 0) {
return [
null,
new IOResult({ exitCode: NOT_FOUND_STATUS, stderr: errBytes }),
new ExecutionNode({ command: cmdStr, exitCode: NOT_FOUND_STATUS }),
];
}
const job = await abortable(waitFirst(jobTable, candidates, signal), signal);
if (varName !== null && view !== null)
await view.set(varName, String(job.pid));
const [stdout, io, node] = await reaped(jobTable, job, cmdStr);
if (errBytes !== null)
io.stderr = errBytes;
return [stdout, io, node];
}
if (specs.length === 0) {
await abortable(jobTable.waitAll(sid, signal), signal);
await jobTable.popCompleted(sid);
return [null, new IOResult(), new ExecutionNode({ command: cmdStr, exitCode: 0 })];
}
if (picked.length === 0) {
// Every spec was refused: bash answers 127 for a job it cannot find
// and 1 for a word that is not a spec at all, the last one deciding.
const last = errors[errors.length - 1] ?? '';
const code = last.endsWith('not a pid or valid job spec') ? 1 : NOT_FOUND_STATUS;
return jobResult(cmdStr, errText, code);
}
let lastCode = 0;
let lastJob = null;
for (const job of picked) {
const finished = await abortable(jobTable.wait(job.id, sid, signal), signal);
const [, io] = await reaped(jobTable, finished, cmdStr);
lastCode = io.exitCode;
lastJob = finished;
}
// `wait id1 id2` answers with the last id's status, so `-p` names that
// same job however many were waited for. Only the no-operand form
// leaves the variable unset, since it reports no one job.
if (varName !== null && view !== null && lastJob !== null) {
await view.set(varName, String(lastJob.pid));
}
return [
null,
new IOResult({ exitCode: lastCode, stderr: errBytes }),
new ExecutionNode({ command: cmdStr, exitCode: lastCode }),
];
}
/**
* Drop jobs from the table without stopping them. No operand means the
* current job (the newest), `-a` every job, `-r` the running ones, and
* `%N`/`N` specs name jobs; `-h` marks a job to survive SIGHUP and leaves
* it in the table, a no-op here since no hangup is ever delivered. A spec
* naming no job is `no such job`, exit 1, and the others still drop.
*/
export async function handleDisown(jobTable, parts, session = null, _view = null) {
const cmdStr = parts.join(' ');
const sid = sessionOf(session);
const scan = scanOptions(parts.slice(1), 'arh');
if (scan.bad !== null) {
return jobResult(cmdStr, `bash: disown: ${scan.bad}: invalid option\n${builtinUsage('disown')}`, 2);
}
const allJobs = scan.letters.includes('a');
const runningOnly = scan.letters.includes('r');
const keep = scan.letters.includes('h');
const specs = scan.operands;
let targets = [];
const errors = [];
await jobTable.sync(sid);
const jobs = jobTable.listJobs(sid);
if (specs.length > 0) {
for (const spec of specs) {
const [job] = resolveSpec(jobs, spec);
if (job === null) {
errors.push(`bash: disown: ${spec}: no such job`);
continue;
}
targets.push(job);
}
}
else if (allJobs || runningOnly) {
targets = runningOnly ? jobs.filter((j) => j.status === JobStatus.RUNNING) : jobs;
}
else {
const current = jobs[jobs.length - 1];
if (current === undefined) {
return jobResult(cmdStr, 'bash: disown: current: no such job\n', 1);
}
targets = [current];
}
if (!keep) {
for (const job of targets)
await jobTable.disown(job.id, sid);
}
const err = errors.length > 0 ? encodeText(errors.join('\n') + '\n') : null;
const code = errors.length > 0 ? 1 : 0;
return [
null,
new IOResult({ exitCode: code, stderr: err }),
new ExecutionNode({
command: cmdStr,
exitCode: code,
...(err !== null ? { stderr: err } : {}),
}),
];
}
/**
* Foreground a background job: print its command line, then block on it
* and answer its exit code. Its output goes where it always went, as it
* is written, so the command line goes out first: to `sink`, where the
* statement writes, before the job's next bytes. With no operand it takes
* the newest running job, which is bash's current job; when none runs, it
* takes the newest finished one, as `fg %N` would.
*/
export async function handleFg(jobTable, parts, session = null, _view = null, signal, sink) {
const cmdStr = parts.join(' ');
const sid = sessionOf(session);
await jobTable.sync(sid);
const jobs = jobTable.listJobs(sid);
let target;
if (parts.length <= 1) {
const current = jobs.filter((j) => j.status === JobStatus.RUNNING).at(-1) ?? jobs.at(-1);
if (current === undefined) {
const err = encodeText('bash: fg: current: no such job\n');
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 1, stderr: err }),
];
}
target = current;
}
else {
const raw = (parts[1] ?? '').replace(/^%+/, '');
const jobId = Number(raw);
const numbered = Number.isInteger(jobId) ? jobNumbered(jobs, jobId) : null;
if (numbered === null) {
const err = encodeText(`bash: fg: ${parts[1] ?? ''}: no such job\n`);
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 1, stderr: err }),
];
}
target = numbered;
}
const header = encodeText(target.command + '\n');
if (sink !== undefined)
await sink.emit(Channel.STDOUT, header);
const job = await abortable(jobTable.wait(target.id, sid, signal), signal);
await jobTable.reap(target.id, sid);
return [
sink === undefined ? header : null,
new IOResult({ exitCode: job.exitCode }),
new ExecutionNode({ command: cmdStr, exitCode: job.exitCode }),
];
}
// The signals a managed runner answers besides the probe (0). Each one ends
// the runner through its cancellation channel, carrying the signal: a
// native program the runner started receives it, KILL a second later if
// it is still running, and the waited status is 128 plus it, as bash
// reports a child the signal ended. A non-interactive bash's asynchronous
// commands may ignore INT and QUIT, job control being off; a managed
// runner ends on them as on any other. Stop, continue and the user signals
// have no managed meaning and are refused as bash refuses a name it does
// not know.
const KILL_SIGNALS = {
HUP: 1,
INT: 2,
QUIT: 3,
KILL: 9,
TERM: 15,
};
// The largest PID operand kill and ps read as a number: the bound both hosts
// hold exactly (bash's own is intmax_t).
const MAX_PID_OPERAND = Number.MAX_SAFE_INTEGER;
/** bash's sigspec: a number, or a name with or without SIG, any case. */
function signalNumber(spec) {
if (/^[0-9]+$/.test(spec)) {
const number = Number(spec);
return number === 0 || Object.values(KILL_SIGNALS).includes(number) ? number : null;
}
const name = spec.toUpperCase();
return KILL_SIGNALS[name.startsWith('SIG') ? name.slice(3) : name] ?? null;
}
/** The managed PID one kill operand names, or bash's refusal. */
function killPid(jobs, operand) {
if (operand === '')
return [null, "`': not a pid or valid job spec"];
if (operand.startsWith('%')) {
const raw = operand.slice(1);
const job = /^[0-9]+$/.test(raw) ? jobNumbered(jobs, Number(raw)) : null;
return job !== null ? [job.pid, ''] : [null, `${operand}: no such job`];
}
const digits = operand.startsWith('-') ? operand.slice(1) : operand;
if (!/^[0-9]+$/.test(digits) || Number(digits) > MAX_PID_OPERAND)
return [null, `${operand}: arguments must be process or job IDs`];
return [Number(operand), ''];
}
/**
* The job a `%N` operand names when it has no runner here: another
* workspace runs it, or it was restored from a snapshot. It is killed
* through the job table, which ends another workspace's job by ending its
* console for its owner to see, and never through this workspace's process
* table, where its stand-in PID may name a runner of this workspace's own.
* Mirrors Python's `_runnerless`.
*/
function runnerless(jobs, operand) {
const raw = operand.startsWith('%') ? operand.slice(1) : '';
const job = /^\d+$/.test(raw) ? jobNumbered(jobs, Number(raw)) : null;
return job !== null && job.process === null ? job : null;
}
/**
* Signal managed runners with bash's kill surface. The signal comes from
* `-s`/`-n`, or from the first `-sigspec`; `0` probes and every other
* signal cancels the runner with it. Every operand is tried and each failure is
* named in bash's words; the status is 0 when any operand was signalled,
* as bash's is. A job spec is `%N`; a negative number is a process group,
* which no managed runner leads. `owner:pid` names another workspace's
* runner, as `ps` lists it: its owner stops it once it takes the stop.
*/
export async function handleKill(jobTable, parts, session = null, _view = null) {
const cmdStr = parts.join(' ');
const sid = sessionOf(session);
let signal = KILL_SIGNALS.TERM ?? 15;
let words = parts.slice(1);
let sawSignal = false;
// The program (`xargs kill`) keeps its bare voice.
const voice = session !== null && isProgramInvocation(session) ? '' : 'bash: ';
while (words.length > 0) {
const word = words[0] ?? '';
let spec;
if (word === '-s' || word === '-n') {
if (words.length < 2)
return jobResult(cmdStr, `${voice}kill: ${word}: option requires an argument\n`, 1);
spec = words[1] ?? '';
words = words.slice(2);
}
else if (word === '--') {
words = words.slice(1);
break;
}
else if (word === '-?') {
return jobResult(cmdStr, builtinUsage('kill'), 2);
}
else if (word.startsWith('-') && word.length > 1 && !sawSignal) {
spec = word.slice(1);
words = words.slice(1);
sawSignal = true;
}
else
break;
const number = signalNumber(spec);
if (number === null)
return jobResult(cmdStr, `${voice}kill: ${spec}: invalid signal specification\n`, 1);
signal = number;
}
if (words.length === 0)
return jobResult(cmdStr, builtinUsage('kill'), 2);
const processes = processView(jobTable, session);
const errors = [];
let signalled = false;
await jobTable.sync(sid);
for (const operand of words) {
const colon = operand.indexOf(':');
if (colon >= 0 && !operand.startsWith('%')) {
const owner = operand.slice(0, colon);
const owned = operand.slice(colon + 1);
if (owner === '' || !/^[0-9]+$/.test(owned)) {
errors.push(`${voice}kill: ${operand}: arguments must be process or job IDs`);
continue;
}
let found;
try {
found = await jobTable.signalRemote(owner, Number(owned), signal, sid, processes.permissions);
}
catch (err) {
if (err.code !== 'EPERM')
throw err;
errors.push(`${voice}kill: (${operand}) - Operation not permitted`);
continue;
}
if (!found) {
errors.push(`${voice}kill: (${operand}) - No such process`);
continue;
}
signalled = true;
continue;
}
const jobs = jobTable.listJobs(sid);
const [pid, refusal] = killPid(jobs, operand);
if (pid === null) {
errors.push(`${voice}kill: ${refusal}`);
continue;
}
const unrun = runnerless(jobs, operand);
if (unrun !== null) {
const found = signal === 0
? unrun.status === JobStatus.RUNNING
: await jobTable.kill(unrun.id, sid, signal);
if (!found) {
errors.push(`${voice}kill: (${String(pid)}) - No such process`);
continue;
}
signalled = true;
continue;
}
let found;
try {
if (signal === 0)
found = processes.probe(pid);
else {
found = processes.terminate(pid, signal);
const job = jobs.find((j) => j.process !== null && j.pid === pid);
if (found && job !== undefined)
await jobTable.kill(job.id, sid, signal);
}
}
catch (err) {
if (err.code !== 'EPERM')
throw err;
errors.push(`${voice}kill: (${String(pid)}) - Operation not permitted`);
continue;
}
if (!found) {
errors.push(`${voice}kill: (${String(pid)}) - No such process`);
continue;
}
signalled = true;
}
const code = signalled ? 0 : 1;
const stderr = errors.length > 0 ? encodeText(errors.join('\n') + '\n') : null;
return [
null,
new IOResult({ exitCode: code, stderr }),
new ExecutionNode({ command: cmdStr, exitCode: code, stderr: stderr ?? new Uint8Array() }),
];
}
const JOBS_FLAGS = new Set('lnprs');
/** One `jobs` line; `-l` includes the managed PID. */
function jobRow(job, long) {
const id = job.id.toString();
return long
? `[${id}] ${String(job.pid)} ${job.status} ${job.command}`
: `[${id}] ${job.status} ${job.command}`;
}
/**
* List jobs, with bash's flags applied to mirage's row shape.
*
* `-p` prints the managed PID; `-s` lists nothing because suspended
* processes are unsupported. `-r` keeps running jobs, `-l` adds the PID, and
* `-n` lists only the jobs whose status changed since the last `jobs`
* (which is every completed one not yet reaped, since reaping is what a
* listing does). A jobspec operand (`%2` or `2`) filters to that job;
* one that names no job is `no such job`, exit 1. `-x` is not carried,
* and an unknown letter is GNU's usage line, exit 2.
*/
export async function handleJobs(jobTable, parts, session = null, _view = null) {
const cmdStr = parts.join(' ');
const sid = sessionOf(session);
const flags = new Set();
const specs = [];
for (const word of parts.slice(1)) {
if (word.startsWith('-') && word.length > 1 && specs.length === 0) {
if (word === '--')
continue;
const bad = Array.from(word.slice(1)).find((c) => !JOBS_FLAGS.has(c));
if (bad !== undefined) {
const err = encodeText(`bash: jobs: -${bad}: invalid option\n${builtinUsage('jobs')}`);
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 2, stderr: err }),
];
}
for (const c of word.slice(1))
flags.add(c);
}
else {
specs.push(word);
}
}
for (let table = jobTable; table !== null; table = table.parent)
await table.sync(sid);
let jobs = jobTable.listing(sid);
if (specs.length > 0) {
const picked = [];
for (const spec of specs) {
const raw = spec.replace(/^%+/, '');
const job = /^\d+$/.test(raw) ? jobNumbered(jobs, Number(raw)) : null;
if (job === null) {
const err = encodeText(`bash: jobs: ${spec}: no such job\n`);
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmdStr, exitCode: 1, stderr: err }),
];
}
picked.push(job);
}
jobs = picked;
}
if (flags.has('r'))
jobs = jobs.filter((j) => j.status === JobStatus.RUNNING);
if (flags.has('s'))
jobs = [];
if (flags.has('n'))
jobs = jobs.filter((j) => j.status !== JobStatus.RUNNING);
const lines = flags.has('p')
? jobs.map((j) => String(j.pid))
: jobs.map((j) => jobRow(j, flags.has('l')));
await jobTable.popCompleted(sid);
const out = lines.length > 0 ? encodeText(`${lines.join('\n')}\n`) : new Uint8Array();
return [out, new IOResult(), new ExecutionNode({ command: cmdStr, exitCode: 0 })];
}
// procps-ng 4.0.4's usage block, printed under every option error.
const PS_USAGE = '\nUsage:\n ps [options]\n\n' +
" Try 'ps --help <simple|list|output|threads|misc|all>'\n" +
" or 'ps --help <s|l|o|t|m|a>'\n" +
' for additional help text.\n\n' +
'For more details see ps(1).\n';
// procps-ng 4.0.4's -o keys: the header, width and right alignment measured
// from its `ps -o` output, and the fact `psCell` renders; accounting a runner
// lacks prints procps's none.
const PS_COLUMNS = {
pid: ['PID', 7, true, 'pid'],
tgid: ['TGID', 7, true, 'pid'],
lwp: ['LWP', 7, true, 'pid'],
spid: ['SPID', 7, true, 'pid'],
tid: ['TID', 7, true, 'pid'],
ppid: ['PPID', 7, true, 'ppid'],
pgid: ['PGID', 7, true, 'pgid'],
pgrp: ['PGRP', 7, true, 'pgid'],
sid: ['SID', 7, true, 'sid'],
sess: ['SESS', 7, true, 'sid'],
tpgid: ['TPGID', 7, true, 'tpgid'],
stat: ['STAT', 4, false, 'stat'],
state: ['S', 1, false, 'state'],
s: ['S', 1, false, 'state'],
cmd: ['CMD', 27, false, 'args'],
args: ['COMMAND', 27, false, 'args'],
command: ['COMMAND', 27, false, 'args'],
comm: ['COMMAND', 15, false, 'comm'],
ucmd: ['CMD', 15, false, 'comm'],
ucomm: ['COMMAND', 15, false, 'comm'],
user: ['USER', 8, false, 'user'],
euser: ['EUSER', 8, false, 'user'],
uname: ['USER', 8, false, 'user'],
ruser: ['RUSER', 8, false, 'user'],
suser: ['SUSER', 8, false, 'user'],
fuser: ['FUSER', 8, false, 'user'],
uid: ['UID', 5, true, 'user'],
euid: ['EUID', 5, true, 'user'],
ruid: ['RUID', 5, true, 'user'],
suid: ['SUID', 5, true, 'user'],
fuid: ['FUID', 5, true, 'user'],
gid: ['GID', 5, true, 'group'],
egid: ['EGID', 5, true, 'group'],
rgid: ['RGID', 5, true, 'group'],
group: ['GROUP', 8, false, 'group'],
egroup: ['EGROUP', 8, false, 'group'],
rgroup: ['RGROUP', 8, false, 'group'],
tty: ['TT', 8, false, 'tty'],
tt: ['TT', 8, false, 'tty'],
tname: ['TTY', 8, false, 'tty'],
time: ['TIME', 8, true, 'time'],
cputime: ['TIME', 8, true, 'time'],
cputimes: ['TIME', 8, true, 'zero'],
etime: ['ELAPSED', 11, true, 'etime'],
etimes: ['ELAPSED', 7, true, 'etimes'],
lstart: ['STARTED', 24, true, 'lstart'],
start: ['STARTED', 8, true, 'start'],
start_time: ['START', 5, false, 'stime'],
stime: ['STIME', 5, false, 'stime'],
bsdstart: ['START', 6, true, 'bsdstart'],
rss: ['RSS', 5, true, 'zero'],
rssize: ['RSS', 5, true, 'zero'],
rsz: ['RSZ', 5, true, 'zero'],
vsz: ['VSZ', 6, true, 'zero'],
vsize: ['VSZ', 6, true, 'zero'],
sz: ['SZ', 5, true, 'zero'],
trs: ['TRS', 4, true, 'zero'],
drs: ['DRS', 5, true, 'zero'],
dsiz: ['DSIZ', 4, true, 'zero'],
size: ['SIZE', 5, true, 'zero'],
pss: ['PSS', 5, true, 'zero'],
uss: ['USS', 5, true, 'zero'],
maj_flt: ['MAJFL', 6, true, 'zero'],
min_flt: ['MINFL', 6, true, 'zero'],
majflt: ['MAJFLT', 6, true, 'zero'],
minflt: ['MINFLT', 6, true, 'zero'],
'%cpu': ['%CPU', 4, true, 'percent'],
pcpu: ['%CPU', 4, true, 'percent'],
'%mem': ['%MEM', 4, true, 'percent'],
pmem: ['%MEM', 4, true, 'percent'],
c: ['C', 2, true, 'zero'],
cp: ['CP', 3, true, 'zero'],
ni: ['NI', 3, true, 'zero'],
nice: ['NI', 3, true, 'zero'],
pri: ['PRI', 3, true, 'pri'],
priority: ['PRI', 3, true, 'priority'],
opri: ['PRI', 3, true, 'opri'],
rtprio: ['RTPRIO', 6, true, 'dash'],
cls: ['CLS', 3, true, 'cls'],
class: ['CLS', 3, false, 'cls'],
policy: ['POL', 3, false, 'cls'],
psr: ['PSR', 3, true, 'zero'],
nlwp: ['NLWP', 4, true, 'one'],
thcount: ['THCNT', 5, true, 'one'],
f: ['F', 1, false, 'zero'],
flag: ['F', 1, false, 'zero'],
flags: ['F', 1, false, 'zero'],
wchan: ['WCHAN', 6, false, 'dash'],
nwchan: ['WCHAN', 6, true, 'dash'],
label: ['LABEL', 31, false, 'dash'],
};
// The fixed answers for a runner: what procps prints for a process on no
// terminal, never scheduled away from the default policy and priority.
const PS_FIXED = {
tpgid: '-1',
tty: '?',
time: '00:00:00',
zero: '0',
one: '1',
percent: '0.0',
pri: '19',
priority: '20',
opri: '80',
dash: '-',
cls: 'TS',
};
// A runner's state letter: live, being cancelled (still unwinding), or exited
// and not yet reaped.
const PS_STATES = {
running: 'R',
stopping: 'R',
exited: 'Z',
};
// Letters that select every process: SysV -e/-A/-a/-x, BSD a/x.
const PS_ALL = new Set(['e', 'A', 'a', 'x']);
/**
* One `-p` list, refused in procps's words: this workspace's PIDs, and
* other workspaces' as `owner:pid`.
*/
function psPids(value, option) {
const tokens = value.split(/[\s,]+/).filter((t) => t !== '');
if (tokens.length === 0)
throw new Error(`list of process IDs must follow ${option}`);
return tokens.map((token) => {
const colon = token.indexOf(':');
if (colon >= 0) {
const owner = token.slice(0, colon);
const pid = token.slice(colon + 1);
if (owner === '' || !/^[0-9]+$/.test(pid))
throw new Error('process ID list syntax error');
return [owner, Number(pid)];
}
if (!/^[+-]?[0-9]+$/.test(token))
throw new Error('process ID list syntax error');
const number = Number(token);
if (number <= 0 || number > MAX_PID_OPERAND)
throw new Error('process ID out of range');
return number;
});
}
/** One `-o` list: `key` or `key=header`, refused in procps's words. */
function psColumns(value, option) {
if (value.trim() === '')
throw new Error(`format specification must follow ${option}`);
const columns = [];
for (const item of value.split(',')) {
if (item.trim() === '')
throw new Error('improper format list');
for (const token of item.split(/\s+/).filter((t) => t !== '')) {
const equal = token.indexOf('=');
const key = equal < 0 ? token : token.slice(0, equal);
const column = PS_COLUMNS[key];
if (column === undefined)
throw new Error(`unknown user-defined format specifier "${key}"`);
columns.push([key, equal < 0 ? column[0] : token.slice(equal + 1)]);
}
}
return columns;
}
/**
* Parse the procps selection and output options a runner answers: SysV
* letters after one dash, BSD letters with none, and the `--pid`/`--format`
* long forms; `-p` and `-o` repeat and accumulate. `-f` and BSD `u`/`w`/`f`
* pick a layout the managed rows do not have, so they leave the compact one.
*/
function parsePs(words) {
const pids = new Set();
const remote = [];
const select = (picked) => {
for (const pid of picked) {
if (typeof pid === 'number')
pids.add(pid);
else
remote.push(pid);
}
};
const columns = [];
let all = false;
let at = 0;
while (at < words.length) {
const word = words[at++] ?? '';
if (word.startsWith('--')) {
const equal = word.indexOf('=');
const option = equal < 0 ? word : word.slice(0, equal);
if (option !== '--pid' && option !== '--format')
throw new Error('unknown gnu long option');
const value = equal < 0 ? (words[at++] ?? '') : word.slice(equal + 1);
if (option === '--pid')
select(psPids(value, option));
else
for (const item of psColumns(value, option))
columns.push(item);
continue;
}
if (!word.startsWith('-')) {
if (!/^[auxwf]+$/.test(word))
throw new Error('unsupported option (BSD syntax)');
all ||= /[ax]/.test(word);
continue;
}
let letters = word.slice(1);
while (letters !== '') {
const flag = letters.charAt(0);
letters = letters.slice(1);
if (PS_ALL.has(flag)) {
all = true;
continue;
}
if (flag === 'f')
continue;
if (flag !== 'p' && flag !== 'o')
throw new Error('unsupported SysV option');
const value = letters !== '' ? letters : (words[at++] ?? '');
letters = '';
if (flag === 'p')
select(psPids(value, '-p'));
else
for (const item of psColumns(value, '-o'))
columns.push(item);
}
}
return { pids, remote, all, columns };
}
/** One row in procps's layout: each column padded but the last. */
function psRow(keys, cells) {
return keys
.map((key, at) => {
const [, width, right] = PS_COLUMNS[key] ?? ['', 0, false, ''];
const cell = cells[at] ?? '';
if (right)
return cell.padStart(width);
return at === keys.length - 1 ? cell : cell.padEnd(width);
})
.join(' ');
}
/** procps's etime: `[[DD-]hh:]mm:ss`. */
function elapsed(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const two = (n) => String(n).padStart(2, '0');
if (days > 0)
return `${String(days)}-${two(hours)}:${two(minutes)}:${two(secs)}`;
if (hours > 0)
return `${two(hours)}:${two(minutes)}:${two(secs)}`;
return `${two(minutes)}:${two(secs)}`;
}
/**
* One start-time column, lstart, start, stime or bsdstart, in the layouts
* measured from procps-ng 4.0.4's `ps -o` output: a day-old start prints its
* date, a recent one its clock.
*/
function started(fact, info, ctx) {
const start = new Date(info.startedAt * 1000);
if (fact === 'lstart')
return gnuStrftime(start, '%a %b %e %H:%M:%S %Y', ctx.zone);
const old = ctx.now - info.startedAt > 86400;
if (fact === 'start')
return gnuStrftime(start, old ? ' %b %d' : '%H:%M:%S', ctx.zone);
if (fact === 'bsdstart')
return gnuStrftime(start, old ? '%b %e' : '%H:%M', ctx.zone);
const now = ctx.zone.parts(new Date(ctx.now * 1000));
const then = ctx.zone.parts(start);
if (now.year !== then.year)
return gnuStrftime(start, '%Y', ctx.zone);
if (now.month !== then.month || now.day !== then.day) {
return gnuStrftime(start, '%b%d', ctx.zone);
}
return gnuStrftime(start, '%H:%M', ctx.zone);
}
/**
* One -o cell for a managed runner. The owner columns print the workspace user
* and the session's profile, names in the id columns too, as `id` does, and
* `-` where nobody claimed one or the runner is another session's, whose
* profile this one cannot name. A runner of the calling session belongs to the
* session `$$` leads; another session's to its own group.
*/
function psCell(key, info, ctx, owner = '') {
const fact = PS_COLUMNS[key]?.[3] ?? '';
const fixed = PS_FIXED[fact];
if (fixed !== undefined)
return fixed;
const tag = owner !== '' ? `${owner}:` : '';
const session = owner === '' && info.sessionId === ctx.sessionId && ctx.shellPid !== null
? ctx.shellPid
: info.groupId || info.pid;
if (fact === 'pid')
return `${tag}${String(info.pid)}`;
if (fact === 'ppid')
return info.parentPid ? `${tag}${String(info.parentPid)}` : '0';
if (fact === 'pgid')
return `${tag}${String(info.groupId || info.pid)}`;
if (fact === 'sid')
return `${tag}${String(session)}`;
if (fact === 'stat' || fact === 'state') {
return PS_STATES[info.state] + (fact === 'stat' && info.pid === session ? 's' : '');
}
if (fact === 'comm') {
const head = info.command.split(/\s+/).find((w) => w !== '') ?? '';
return (head.split('/').pop() ?? '').slice(0, 15);
}
if (fact === 'user' || fact === 'group') {
return (info.sessionId === ctx.sessionId ? ctx.group : null) ?? UNKNOWN_NAME;
}
const age = Math.max(0, Math.floor(ctx.now - info.startedAt));
if (fact === 'etime')
return elapsed(age);
if (fact === 'etimes')
return String(age);
if (['lstart', 'start', 'stime', 'bsdstart'].includes(fact))
return started(fact, info, ctx);
return info.command;
}
/** Another workspace's runner as its process record keeps it. */
function remoteInfo(record) {
return {
pid: record.pid,
sessionId: record.session,
command: record.command,
cwd: PathSpec.fromStrPath(record.cwd),
startedAt: record.started_at,
executionId: record.execution,
state: record.state,
cancellationRequested: false,
exitCode: null,
failure: null,
parentPid: record.parent,
groupId: record.group,
};
}
/**
* The shortest start of each owner's id, six characters at least, that no
* other owner's begins with, as `ps` names their processes.
*/
export function ownerTags(owners) {
const tags = new Map();
for (const owner of owners) {
let size = 6;
while (size < owner.length &&
owners.some((other) => other !== owner && other.startsWith(owner.slice(0, size))))
size += 1;
tags.set(owner, owner.slice(0, size));
}
return tags;
}
/**
* Where a native run goes on, as `ps` names it: `host PID` for a host
* program, the runtime and the start of its tag for a sandbox's.
*/
function runWhere(run) {
if ('host' in run)
return `host ${String(run.pid)}`;
return `${run.runtime} ${run.tag.slice(0, 12)}`;
}
/**
* List managed runners with procps's selection and `-o` columns. A runner
* has no CPU, RSS or TTY accounting, so without `-o` the rows stay mirage's
* compact `PID<TAB>COMMAND` and never broaden the profile's view. `-o` lays
* out procps-ng 4.0.4's columns, every key a runner can answer (PS_COLUMNS);
* a header row prints unless every header is empty. Selecting nothing (`-p`
* of an absent PID) exits 1, as procps does, and an option error is
* procps's message and usage.
*
* The runners other workspaces on a shared store run follow this
* workspace's, as `owner:pid` with the owner's id shortened as far as it
* stays unique, within the same view. In the compact rows each runner is followed by the native runs
* it has going, a host program by its host PID and a sandbox's by its
* runtime and tag, each as `-<TAB>â where: command`.
*/
export async function handlePs(jobTable, parts, session = null, _view = null) {
const cmdStr = parts.join(' ');
let options;
try {
options = parsePs(parts.slice(1));
}
catch (err) {
return jobResult(cmdStr, `error: ${err.message}\n${PS_USAGE}`, 1);
}
const sid = sessionOf(session);
const processes = processView(jobTable, session);
const every = options.all || (options.pids.size === 0 && options.remote.length === 0);
const runs = new Map(jobTable.processes.live().map((handle) => [handle.info.pid, Object.values(handle.runs)]));
const rows = processes
.list()
.filter((info) => every || options.pids.has(info.pid))
.map((info) => ['', info, runs.get(info.pid) ?? []]);
const remote = await jobTable.remoteProcesses();
const granted = processes.permissions();
const tags = ownerTags(Object.keys(remote));
for (const [owner, records] of Object.entries(remote)) {
for (const record of Object.values(records)) {
if (granted === null || (granted.list === 'session' && record.session !== sid))
continue;
if (every ||
options.remote.some(([prefix, pid]) => owner.startsWith(prefix) && record.pid === pid))
rows.push([
tags.get(owner) ?? owner,
remoteInfo(record),
record.runs,
]);
}
}
let lines;
if (options.columns.length > 0) {
const keys = options.columns.map(([key]) => key);
const ctx = {
now: systemNow(),
zone: (session !== null ? zoneFromEnv(session.env) : null) ?? LOCAL_ZONE,
group: session?.profile ?? null,
sessionId: sid,
shellPid: session?.shellPid ?? null,
};
lines = rows.map(([owner, info]) => psRow(keys, keys.map((key) => psCell(key, info, ctx, owner))));
if (options.columns.some(([, header]) => header !== ''))
lines.unshift(psRow(keys, options.columns.map(([, header]) => header)));
}
else {
lines = rows.flatMap(([owner, info, native]) => [
`${owner !== '' ? `${owner}:` : ''}${String(info.pid)}\t${info.command}`,
...native.map((run) => `-\tâ ${runWhere(run)}: ${run.command}`),
]);
}
const code = rows.length > 0 ? 0 : 1;
const out = encodeText(lines.length > 0 ? lines.join('\n') + '\n' : '');
return [
out,
new IOResult({ exitCode: code }),
new ExecutionNode({ command: cmdStr, exitCode: code }),
];
}
//# sourceMappingURL=jobs.js.map