UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

453 lines • 19.7 kB
// ========= 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 { STRTOD, strtodDouble } from "../../../../commands/builtin/utils/strtod.js"; import { boundedDecimal, INT_MAX } from "../../../../commands/builtin/utils/strtol.js"; import { quoteText } from "../../../../commands/quote.js"; import { renderHelp } from "../../../../commands/spec/help.js"; import { parseShellOptions, SHELL_SPECS } from "../../../../commands/spec/shell.js"; import { versionLine } from "../../../../commands/spec/standard.js"; import { SYNOPSES } from "../../../../commands/spec/synopsis.js"; import { ambiguousOptionError, missingValueError, unexpectedValueError, unknownOptionError, usageHint, } from "../../../../commands/spec/usage.js"; import { runAsProgram } from "../../../../context/session_context.js"; import { ensureStream, OutputStream, yieldBytes } from "../../../../io/stream.js"; import { IOResult, materialize, OutputState } from "../../../../io/types.js"; import { encodeText } from "../../../../shell/bytes.js"; import { Channel } from "../../../../shell/console/index.js"; import { PipeConsole } from "../../../../shell/console/pipe.js"; import { NOT_FOUND_STATUS, TIMEOUT_STATUS } from "../../../../shell/constants.js"; import { STOP_WAIT_SECONDS } from "../../../../shell/job_table/constants.js"; import { shellJoin } from "../../../../shell/join.js"; import { abortable, atDeadline, runCancellable, SignalStop } from "../../../../utils/abort.js"; import { concat } from "../../../../utils/bytes.js"; import { execs } from "../../../lookup/lookup.js"; import { ExecutionNode } from "../../../types.js"; import { CONTINUE_SIGNALS, SELF_KILLING_SIGNALS, SIGCHLD, SIGKILL, SIGNAL_ALIASES, SIGNALS, SIGRTMAX, SIGRTMIN, SIGSTOP, STOP_SIGNALS, TIMEOUT_NAMES, } from "./constants.js"; const UNIT_SECONDS = Object.freeze({ '': 1, s: 1, m: 60, h: 3600, d: 86400, }); const TIMED_OUT = Symbol('timed-out'); function refuse(stderr, exitCode = 125) { return [ null, new IOResult({ exitCode, stderr }), new ExecutionNode({ command: 'timeout', exitCode }), ]; } function usageError(message) { // GNU timeout reserves 125 for its own failures; 124 means the // command was killed at the deadline. return refuse(encodeText(`timeout: ${message}\n${usageHint('timeout')}\n`)); } /** GNU's report of a command timeout finds nothing to run for. */ export function timeoutMissing(name) { return `timeout: failed to run command '${quoteText(name)}': No such file or directory\n`; } /** * A timeout DURATION in seconds: a C float plus an optional s/m/h/d. * * GNU timeout 9.7 takes the longest C-locale floating-point number at the * front of the word, leading whitespace, an exponent, a hex float and inf * included; at most one suffix letter may follow, and a negative or NaN * interval is refused. */ export function durationSeconds(raw) { const match = STRTOD.exec(raw); if (match === null) return null; const multiplier = UNIT_SECONDS[raw.slice(match[0].length)]; if (multiplier === undefined) return null; const value = strtodDouble(match); if (!(value >= 0)) return null; return (value === 0 ? 0 : value) * multiplier; } // The offset after RTMIN or RTMAX: C blanks, then an optional sign, then // decimal digits and nothing more (`RTMIN 2`, `RTMAX-01`; not `RTMIN+ 2`). const RT_OFFSET = /^[ \t\n\v\f\r]*([+-]?)([0-9]+)$/; function decimal(digits) { if (!/^[0-9]+$/.test(digits)) return null; const value = boundedDecimal(digits, INT_MAX + 1n); return value === null ? null : Number(value); } /** * The signal a bare number names, as GNU timeout 9.7 reads one: 0 to 64 * name themselves; 128 to 192 are a `$?` of 128 plus the signal, and from * 256 on only the low byte counts, which must then be 0 to 64 (`265` is * KILL, `384` nothing). Mirrors Python's _status_signal. */ function statusSignal(value) { if (value <= SIGRTMAX) return value; if (value < 256) return value >= 128 && value <= 128 + SIGRTMAX ? value - 128 : null; const low = value % 256; return low <= SIGRTMAX ? low : null; } function realtime(name) { for (const [base, start] of [ ['RTMIN', SIGRTMIN], ['RTMAX', SIGRTMAX], ]) { if (!name.startsWith(base)) continue; const rest = name.slice(base.length); if (rest === '') return start; const match = RT_OFFSET.exec(rest); const offset = match === null ? null : decimal(match[2] ?? ''); if (match === null || offset === null) return null; const value = match[1] === '-' ? start - offset : start + offset; return value >= SIGRTMIN && value <= SIGRTMAX ? value : null; } return null; } /** * The signal a `-s` value names, null if it names none. Measured on GNU * timeout 9.7: a number (decimal digits only, no sign or blank) may carry a * shell's 128 or 256 offset, as `$?` does; a name is read in any case, with * or without `SIG`, and `SIG` before digits takes only 0 to 64. `RTMIN+n` * and `RTMAX-n` stay inside the realtime range. Mirrors Python's * parse_signal. */ export function parseSignal(operand) { for (let i = 0; i < operand.length; i += 1) if (operand.charCodeAt(i) > 0x7f) return null; if (/^[0-9]+$/.test(operand)) { const value = decimal(operand); return value === null ? null : statusSignal(value); } const upper = operand.toUpperCase(); const name = upper.startsWith('SIG') ? upper.slice(3) : upper; if (/^[0-9]+$/.test(name)) { const value = decimal(name); return value !== null && value <= SIGRTMAX ? value : null; } const alias = SIGNAL_ALIASES.get(name); if (alias !== undefined) return alias; for (const [number, known] of SIGNALS) if (known === name) return number; return realtime(name); } /** * A signal's name as `timeout -v` prints it, or its number. The realtime * range is named from RTMIN up to its middle (RTMIN+15 is 49) and from RTMAX * above it (50 is RTMAX-14), as GNU 9.7 and bash do. Mirrors Python's * signal_name. */ export function signalName(number) { const named = TIMEOUT_NAMES.get(number) ?? SIGNALS.get(number); if (named !== undefined) return named; if (number < SIGRTMIN || number > SIGRTMAX) return String(number); if (number === SIGRTMIN) return 'RTMIN'; if (number === SIGRTMAX) return 'RTMAX'; if (number - SIGRTMIN <= Math.floor((SIGRTMAX - SIGRTMIN) / 2)) { return `RTMIN+${String(number - SIGRTMIN)}`; } return `RTMAX-${String(SIGRTMAX - number)}`; } // Run the inner line and drain its output under the same deadline. The line // writes into `sink` as it runs, a program as it prints and a list // statement by statement, so a run the deadline cuts short keeps what it had // printed, as GNU's leaves it on the terminal. A lazy stdout it still // returns drains inside the deadline too. async function executeDrained(executeFn, inner, sessionId, stdin, sink, held, signal) { const io = await executeFn(inner, { sessionId, signal, sink, ...(stdin !== null ? { stdin } : {}), }); held.push(io); if (io.stdout !== null) { for await (const chunk of ensureStream(io.stdout)) await sink.emit(Channel.STDOUT, chunk); } const stderr = concat([await sink.snapshot(Channel.STDERR), await io.materializeStderr()]); io.stderr = stderr.byteLength > 0 ? stderr : null; return io; } // `reached` runs at the deadline itself: a guest that holds the event loop // (QuickJS) meets it there, where nothing waiting on the race can run. async function raceDeadline(run, seconds, reached) { let cancel = () => undefined; const deadline = new Promise((resolve) => { cancel = atDeadline(seconds, () => { resolve(TIMED_OUT); reached(); }); }); try { return await Promise.race([run, deadline]); } finally { cancel(); } } /** * Run `timeout [OPTION] DURATION COMMAND [ARG...]` (GNU timeout). * * The inner line is built with shellJoin so already-expanded words survive * re-parsing as one token each (GNU timeout execs the command without a * shell). The command reads timeout's stdin. At the deadline the command * gets -s's signal (TERM by default), and a command here is a promise, not a * process, so the signal's default action decides: one that terminates * aborts the run, and 124 reports it, or the status a shell would show with * --preserve-status; one that is ignored (0, CHLD, CONT, URG, WINCH) lets * the run carry on, and -k's KILL ends it later with 137; a stop halts it * until that KILL. timeout signals its own process group unless * --foreground, so it dies of what it cannot ignore (KILL, 32, 33), stops * itself on STOP, and never wakes after CHLD; each ends as GNU's does. -v * says each signal on stderr. The deadline aborts the inner run, not just * the wait for it: a promise cannot be cancelled, so the signal is what * stops a `tail -f` from polling on after 124 was already returned. The * command's stdout goes out as it writes it, as GNU's command writes to * timeout's own stdout; the status, stderr and node settle once that stream * ends. */ // eslint-disable-next-line @typescript-eslint/require-await export async function handleTimeout(executeFn, args, session, stdin = null, registry = null, lineSignal) { const parse = parseShellOptions(SHELL_SPECS.timeout, args); let signal = 15; let killAfter = 0; for (const [name, value] of parse.given) { if (name === 'help') { const text = renderHelp('timeout', SHELL_SPECS.timeout, [], undefined, SYNOPSES.timeout); return [ yieldBytes(encodeText(text)), new IOResult(), new ExecutionNode({ command: 'timeout', exitCode: 0 }), ]; } if (name === 'version') { return [ yieldBytes(encodeText(versionLine('timeout'))), new IOResult(), new ExecutionNode({ command: 'timeout', exitCode: 0 }), ]; } if (name === 'k' && typeof value === 'string') { const after = durationSeconds(value); if (after === null) return usageError(`invalid time interval '${quoteText(value)}'`); killAfter = after < Infinity ? after : 0; } if (name === 's' && typeof value === 'string') { const number = parseSignal(value); if (number === null) return usageError(`'${quoteText(value)}': invalid signal`); signal = number; } } if (parse.invalid !== null) { const [stderr, code] = parse.candidates.length > 0 ? ambiguousOptionError('timeout', parse.invalid, parse.candidates) : unknownOptionError('timeout', parse.invalid); return refuse(stderr, code); } if (parse.unexpectedValue !== null) { return refuse(...unexpectedValueError('timeout', parse.unexpectedValue)); } if (parse.needsValue !== null) return refuse(...missingValueError('timeout', parse.needsValue)); const [raw, ...command] = parse.operands; if (raw === undefined || command.length === 0) { return refuse(encodeText(`${usageHint('timeout')}\n`)); } const seconds = durationSeconds(raw); if (seconds === null) return usageError(`invalid time interval '${quoteText(raw)}'`); const name = command[0] ?? ''; if (registry !== null && !execs(name, session, registry)) { return refuse(encodeText(timeoutMissing(name)), NOT_FOUND_STATUS); } const sink = new PipeConsole(); // The inner result lands here as soon as it exists. const held = []; const abort = new AbortController(); // timeout execs its command, so a builtin that is also a program answers // as the program. const run = runAsProgram(session, () => executeDrained(executeFn, shellJoin(command), session.sessionId, stdin, sink, held, abort.signal)); // The run may be abandoned and reject later; without a handler that // becomes an unhandled rejection and can crash the process. run.catch(() => undefined); const watch = supervise(run, abort, sink, held, seconds, lineSignal, { signal, killAfter, foreground: parse.flags.f === true, preserve: parse.flags.p === true, verbose: parse.flags.v === true, name, }).finally(() => { abort.abort(); sink.end(); }); const io = new IOResult(); const state = new OutputState(); io.output = state; const node = new ExecutionNode({ command: 'timeout', exitCode: 0 }); // A reader that stops early, or never starts, closes the pipe, so the // line's next write fails as a write to a closed pipe does and the run is // let go. The line ran under the command's prefix assignments, which end // once it has unwound, or its grace to unwind has passed. const close = async () => { sink.closeReader(); abort.abort(); await runCancellable(watch, abort.signal, STOP_WAIT_SECONDS * 1000).catch((err) => { console.debug(`timeout: the run ended: ${String(err)}`); }); state.finish(); }; return [new OutputStream(streamed(sink, watch, io, node), close), io, node]; } /** * The inner line's stdout as it writes it; once it ends, timeout's status * and stderr settle on `io` and `node`. */ async function* streamed(sink, watch, io, node) { yield* sink.stream(); const [, result] = await watch; io.exitCode = node.exitCode = result.exitCode; io.stderr = result.stderr; } /** Wait for the run, and at the deadline do what the signal does. */ async function supervise(run, abort, sink, held, seconds, lineSignal, how) { const { signal, killAfter, foreground } = how; const stops = STOP_SIGNALS.has(signal) && (foreground || signal === SIGSTOP); const carriesOn = (signal === SIGCHLD && !foreground) || CONTINUE_SIGNALS.has(signal) || STOP_SIGNALS.has(signal); // -k's KILL, armed at the deadline itself for a run the signal leaves // going, which may hold the event loop past it. const kill = { at: null, cancel: () => undefined, }; const reached = () => { if (stops || !carriesOn) abort.abort(new SignalStop(signal)); if (!carriesOn || killAfter === 0) return; kill.at = new Promise((resolve) => { kill.cancel = atDeadline(killAfter, () => { resolve(TIMED_OUT); abort.abort(new SignalStop(SIGKILL)); }); }); }; // What the stopped run printed up to the stop has gone out. What it // still does as it ends (a guest landing what it wrote) is done before // timeout answers, as Python joins the cancelled run, but what it prints // on stdout then is lost, so the pipe ends at the stop and drops it: a JS // run goes on past its abort, where a cancelled Python task stops at its // next await. // Its stderr is read after the join, since a runtime hands over what its // guest wrote there as the run ends. One that will not end is let go // after a stop's grace. const stop = async (said, exitCode) => { sink.mute(); try { await runCancellable(run, abort.signal, STOP_WAIT_SECONDS * 1000); } catch (err) { console.debug(`timeout: the stopped run ended: ${String(err)}`); } return await ended(sink, held, said, exitCode); }; try { const result = seconds > 0 && seconds < Infinity ? await raceDeadline(run, seconds, reached) : await run; if (result !== TIMED_OUT) { return [ await sink.snapshot(Channel.STDOUT), result, new ExecutionNode({ command: 'timeout', exitCode: result.exitCode }), ]; } const said = []; const say = (number) => { if (how.verbose) { said.push(`timeout: sending signal ${signalName(number)} to command '${quoteText(how.name)}'\n`); } }; say(signal); if (stops || (signal === SIGCHLD && !foreground)) { // The command stops, or timeout never wakes: only -k's KILL ends the // wait, and not even that once timeout stopped itself. if (kill.at === null || (signal === SIGSTOP && !foreground)) { await abortable(new Promise(() => undefined), lineSignal); } await kill.at; say(SIGKILL); return await stop(said, 137); } if (carriesOn) { const late = kill.at === null ? await run : await Promise.race([run, kill.at]); if (late === TIMED_OUT) { say(SIGKILL); return await stop(said, 137); } return await ended(sink, held, said, how.preserve ? late.exitCode : TIMEOUT_STATUS, true); } if (SELF_KILLING_SIGNALS.has(signal) && (!foreground || signal === SIGKILL)) { return await stop(said, 128 + signal); } return await stop(said, how.preserve ? 128 + signal : TIMEOUT_STATUS); } finally { kill.cancel(); } } /** * What a run the deadline reached leaves behind. * * What the command printed before it ended is its output, as GNU leaves it * on the terminal; only the run past that is lost. That goes for stderr * too: a `tail -F missing` has already said it cannot open the file by the * time the deadline kills it. timeout's own -v lines follow it. A run that * ended on its own has its whole stderr, so that is read in full. */ async function ended(sink, held, said, exitCode, finished = false) { const source = held[0]?.stderr ?? null; const head = finished ? await materialize(source) : source instanceof Uint8Array ? source : new Uint8Array(); const tail = encodeText(said.join('')); // A run that ended on its own holds what it wrote into the console too. const early = finished ? new Uint8Array() : await sink.snapshot(Channel.STDERR); const stderr = concat([early, head, tail]); const partial = await sink.snapshot(Channel.STDOUT); return [ partial.byteLength > 0 ? partial : null, new IOResult({ exitCode, stderr: stderr.byteLength > 0 ? stderr : null }), new ExecutionNode({ command: 'timeout', exitCode }), ]; } /** The `timeout` arm. */ export async function timeoutBuiltin(call) { return handleTimeout(call.executeFn, [...call.argv.args], call.context.session, call.stdin, call.registry, call.signal); } //# sourceMappingURL=timeout.js.map