UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,155 lines • 43.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 { UsageError } from "../../../../commands/errors.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 { boundedMapStream } from "../../../../concurrency/limiter.js"; import { runAsProgram, runWithEvaluation } from "../../../../context/session_context.js"; import { enoent, fsStrerror } from "../../../../errors/fs.js"; import { chunks } from "../../../../io/cooperative.js"; import { PipeClosed } from "../../../../io/errors.js"; import { SharedStdin, yieldBytes } from "../../../../io/stream.js"; import { IOResult } from "../../../../io/types.js"; import { decodeText, encodeText } from "../../../../shell/bytes.js"; import { PipeConsole } from "../../../../shell/console/pipe.js"; import { Channel } from "../../../../shell/console/types.js"; import { NOT_FOUND_STATUS, SIGPIPE_STATUS } from "../../../../shell/constants.js"; import { shellJoin } from "../../../../shell/join.js"; import { mergeSignals } from "../../../../utils/abort.js"; import { asyncContextIsolatesTasks } from "../../../../utils/async_context.js"; import { concat, findBytes } from "../../../../utils/bytes.js"; import { shellQuote } from "../../../../utils/quote.js"; import { execs } from "../../../lookup/lookup.js"; import { varsFromEnv } from "../../../session/session.js"; import { envSnapshot } from "../../../session/state.js"; import { ExecutionNode } from "../../../types.js"; import { pump } from "../../jobs.js"; import { readScriptBytes } from "../script/script.js"; // The sizes GNU findutils 4.10 reports with --show-limits on Linux with an // 8 MiB stack: exec takes 2 MiB of arguments and environment, 2 KiB of // which xargs leaves unused, and a command line holds 128 KiB unless -s // says otherwise. -P takes at most a C int. const EXEC_BYTES = 2097152; const UNUSED_BYTES = 2048; const POSIX_FLOOR = 4096; const STANDARD_WIDTH = 131072; const MOST_JOBS = 2147483647; const INTEGER = /^[ \t\n\v\f\r]*[+-]?[0-9]+$/; // The bytes that end a run the quoting reader copies as it is: outside // quotes, inside a whole -I line once it has begun, and inside each quote. const WORD_RUN = new Set([0x20, 0x09, 0x0a, 0x27, 0x22, 0x5c, 0x00]); const LINE_RUN = new Set([0x0a, 0x27, 0x22, 0x5c, 0x00]); const QUOTED_RUN = new Map([ [0x27, new Set([0x27, 0x0a, 0x00])], [0x22, new Set([0x22, 0x0a, 0x00])], ]); const QUOTE_WORDS = new Map([ [0x27, 'single'], [0x22, 'double'], ]); // Vertical tab, form feed and carriage return are skipped before a word but // kept inside one (GNU 4.10: `\ra \vb\r` gives `a` and `b\r`), and skipping // one leaves a line's last blank as it was for -L. const SKIPPED_BEFORE_WORD = new Set([0x0b, 0x0c, 0x0d]); const LETTER_ESCAPES = new Map([ [0x61, 0x07], [0x62, 0x08], [0x66, 0x0c], [0x6e, 0x0a], [0x72, 0x0d], [0x74, 0x09], [0x76, 0x0b], [0x5c, 0x5c], ]); const HEX_DIGITS = /^[0-9a-fA-F]*/; const OCTAL_DIGITS = /^[0-7]*/; const NUL_IN_INPUT = 'xargs: WARNING: a NUL character occurred in the input. It cannot be passed through in the argument list. Did you mean to use the --null option?\n'; const NO_TTY = 'xargs: failed to open /dev/tty for reading: No such device or address\n'; const LINE_TOO_LONG = 'xargs: argument line too long\n'; const LIST_TOO_LONG = 'xargs: argument list too long\n'; const NO_ROOM = 'xargs: cannot fit single argument within argument list size limit\n'; const TEMPLATE_TOO_LONG = 'xargs: command too long\n'; const CROWDED = 'xargs: environment is too large for exec\n'; // What a previous -I, -L or -n is called when a later one cancels it. const CANCELLED = Object.freeze({ template: '--replace', lines: '--max-lines', group: '--max-args', }); function refusal(stderr, code = 1) { const data = typeof stderr === 'string' ? encodeText(stderr) : stderr; return [ null, new IOResult({ stderr: data, exitCode: code }), new ExecutionNode({ command: 'xargs', exitCode: code }), ]; } function answer(option, notes) { // --help and --version answer where they stand, after the warnings of // the options before them. const text = option === 'help' ? renderHelp('xargs', SHELL_SPECS.xargs, [], undefined, SYNOPSES.xargs) : versionLine('xargs'); return [ yieldBytes(encodeText(text)), new IOResult(notes === '' ? {} : { stderr: encodeText(notes) }), new ExecutionNode({ command: 'xargs', exitCode: 0 }), ]; } function conflict(option, previous) { return `xargs: warning: options ${previous} and ${option} are mutually exclusive, ignoring previous ${previous} value\n`; } /** * Read a count option as GNU does: blanks may lead, nothing may trail. A * null ceiling has no bound, so GNU 4.10 runs `-n 99999999999999999999`. */ function wholeNumber(raw, letter, floor, ceiling = null) { const hint = usageHint('xargs'); if (!INTEGER.test(raw)) { throw new UsageError(`xargs: invalid number "${raw}" for -${letter} option\n${hint}\n`, 1); } const value = Number(raw.trim()); if (value < floor) { throw new UsageError(`xargs: value ${raw} for -${letter} option should be >= ${String(floor)}\n${hint}\n`, 1); } if (ceiling !== null && value > ceiling) { throw new UsageError(`xargs: value ${raw} for -${letter} option should be <= ${String(ceiling)}\n${hint}\n`, 1); } return value; } /** * The byte -d names: one character, or a backslash escape. * * A letter escape ignores what follows it (GNU 4.10 takes `\nX` as * newline); a numeric one (`\x41`, `\101`) reads every digit of its base, so * `\x411` is out of range and `\x41z` has a tail. */ function separator(spec) { const raw = encodeText(spec); if (raw.length === 1) return raw[0] ?? 0; if (raw[0] !== 0x5c) { throw new UsageError(`xargs: Invalid input delimiter specification ${spec}: the delimiter must be either a single character or an escape sequence starting with \\.\n`, 1); } const lead = raw[1] ?? 0; const letter = LETTER_ESCAPES.get(lead); if (letter !== undefined) return letter; let digits; let body; let top; if (lead === 0x78) { digits = HEX_DIGITS; body = decodeText(raw.subarray(2)); top = ['ff', 16]; } else if (lead >= 0x30 && lead <= 0x39) { digits = OCTAL_DIGITS; body = decodeText(raw.subarray(1)); top = ['377', 8]; } else { throw new UsageError(`xargs: Invalid escape sequence ${spec} in input delimiter specification.\n`, 1); } const width = (digits.exec(body)?.[0] ?? '').length; const value = width > 0 ? parseInt(body.slice(0, width), top[1]) : 0; if (value > 0xff) { throw new UsageError(`xargs: Invalid escape sequence ${spec} in input delimiter specification; character values must not exceed ${top[0]}.\n`, 1); } if (width < body.length) { throw new UsageError(`xargs: Invalid escape sequence ${spec} in input delimiter specification; trailing characters ${body.slice(width)} not recognised.\n`, 1); } return value; } function limitsReport(environment, room, width) { return (`Your environment variables take up ${String(environment)} bytes\n` + `POSIX upper limit on argument length (this system): ${String(room)}\n` + `POSIX smallest allowable upper limit on argument length (all systems): ${String(POSIX_FLOOR)}\n` + `Maximum length of command we could actually use: ${String(room - environment)}\n` + `Size of command buffer we are actually using: ${String(width)}\n` + `Maximum parallelism (--max-procs must be no greater): ${String(MOST_JOBS)}\n`); } function ttyAbort(name) { // GNU 4.10 without a terminal: the child cannot reopen /dev/tty and dies // of SIGABRT, which xargs reports as 125. return ("xargs: '/dev/tty': No such device or address\n" + "xargs: xargs.c:1648: wait_for_proc_all: Assertion `getpid () == parent' failed.\n" + `xargs: ${decodeText(name)}: terminated by signal 6\n`); } function unclosed(mark) { return `xargs: unmatched ${QUOTE_WORDS.get(mark) ?? ''} quote; by default quotes are special to xargs unless you use the -0 option\n`; } function beforeNul(word) { // An argument is a C string: a NUL ends it, though every byte read still // counts against -s (GNU 4.10: `a\0bbbbbbbbbbbb` needs 15). const cut = word.indexOf(0); return cut < 0 ? word : word.subarray(0, cut); } /** * What the options asked for, in the order they were given. * * -I (`template`), -L/-l (`lines`) and -n (`group`) exclude one another: the * last one given holds, with a warning for the one it cancels, except that * `-n 1` after -I is ignored silently. `room` is what -s may reach once the * environment is counted; `crowded` says the environment alone leaves none. */ class Plan { room; crowded; notes = ''; width = 0; separator = null; endWord = null; template = null; lines = 0; group = 0; jobs = 1; source = '-'; slotVar = null; flags = new Set(); constructor(room, crowded) { this.room = room; this.crowded = crowded; } active() { if (this.template !== null) return 'template'; if (this.lines > 0) return 'lines'; return this.group > 0 ? 'group' : null; } warnOver(option, ...kinds) { const held = this.active(); if (held !== null && kinds.includes(held)) this.notes += conflict(option, CANCELLED[held]); } take(name, value) { if (name === '0') { this.separator = 0; } else if (name === 'd' && value !== null) { this.separator = separator(value); } else if (name === 'E' || name === 'e') { this.endWord = value === null || value === '' ? null : value; } else if (['t', 'p', 'x', 'o', 'r', 'show-limits'].includes(name)) { this.flags.add(name); } else if (name === 'a' && value !== null) { this.source = value; } else if (name === 'process-slot-var' && value !== null) { this.takeSlotVar(value); } else if (name === 's' && value !== null) { this.takeWidth(value); } else if (name === 'I' || name === 'i') { this.warnOver('--replace/-I/-i', 'group', 'lines'); this.template = value ?? '{}'; this.lines = 0; this.group = 0; } else if (name === 'L' || name === 'l') { const count = wholeNumber(value ?? '1', name, 1); this.warnOver(name === 'L' ? '-L' : '--max-lines/-l', 'group', 'template'); this.template = null; this.lines = count; this.group = 0; } else if (name === 'n' && value !== null) { this.takeGroup(wholeNumber(value, 'n', 1)); } else if (name === 'P' && value !== null) { this.jobs = wholeNumber(value, 'P', 0, MOST_JOBS); } } takeGroup(count) { // GNU reads `-I {} -n1` as plain -I. if (this.active() === 'template' && count === 1) return; this.warnOver('--max-args/-n', 'lines', 'template'); this.template = null; this.lines = 0; this.group = count; } takeWidth(value) { if (this.crowded) throw new UsageError(CROWDED, 1); if (!INTEGER.test(value)) { throw new UsageError(`xargs: invalid number "${value}" for -s option\n${usageHint('xargs')}\n`, 1); } let width = Number(value.trim()); if (width < 1) { this.notes += `xargs: value ${value} for -s option should be >= 1\n`; width = 1; } else if (width > this.room) { this.notes += `xargs: value ${value} for -s option should be <= ${String(this.room)}\n`; width = this.room; } this.width = width; } takeSlotVar(value) { if (value.includes('=')) { throw new UsageError("xargs: option --process-slot-var may not be set to a value which includes `='\n", 1); } if (value === '') { throw new UsageError('xargs: failed to unset environment variable : Invalid argument\n', 1); } this.slotVar = value; } } /** * Where the reader's words go: commands to run and messages to print, in * order, and the error that ended the run. `base` is the command name and * its initial arguments, `width` the -s limit in bytes with a NUL counted * per word; -p asks first and -o reopens the terminal, and there is no * terminal, so the first command fails each way. */ class Collector { base; width; flags; out = []; failure = null; ended = false; waiting = false; ask; reopen; traced; constructor(base, width, flags) { this.base = base; this.width = width; this.flags = flags; this.ask = flags.has('p'); this.reopen = flags.has('o'); this.traced = flags.has('t'); } halted() { return this.ended; } holding() { // A built command waits to be run: the reader pauses until it has been // handed on, so one is held at a time. return this.waiting; } drain() { const ready = this.out; this.out = []; this.waiting = false; return ready; } note(text) { this.out.push(text); } stop(text, code = 1) { this.failure ??= [text, code]; this.ended = true; } ship(line) { if (this.ask) { // GNU shows the line it would prompt for, then cannot read the answer. this.stop(trace(line).slice(0, -1) + NO_TTY); } else if (this.reopen) { const shown = this.traced ? trace(line) : ''; this.stop(shown + ttyAbort(line[0] ?? new Uint8Array()), 125); } else { this.out.push(line); this.waiting = true; } } begin() { // Most collectors check nothing before the input. } lineDone() { // Only -L counts lines. } fail(text) { this.stop(text); } close() { this.ended = true; } } /** * Packs words after the initial arguments, as many as -s, -n and -L allow. * * With -x, or -L, a command may not be cut short: a word that does not fit * is `argument list too long` (with -x only when -n asks for a count), and * an error drops the words gathered so far, which otherwise run before * xargs stops. A word too long for any command line is `argument line too * long`. Without -r, input with no words still runs the command once. */ class Packer extends Collector { group; lines; mostWords; strict; whole; runEmpty; fixed; held = []; heldBytes = 0; linesHeld = 0; shipped = false; constructor(base, width, flags, group, lines, mostWords) { super(base, width, flags); this.group = group; this.lines = lines; this.mostWords = mostWords; this.strict = flags.has('x'); this.whole = this.strict || lines > 0; this.runEmpty = !flags.has('r'); this.fixed = base.reduce((sum, word) => sum + word.length + 1, 0); } begin() { if (this.fixed > this.width) this.stop(NO_ROOM); } flush() { const line = [...this.base, ...this.held.map(beforeNul)]; this.held = []; this.heldBytes = 0; this.linesHeld = 0; this.shipped = true; this.ship(line); } wordRoom() { return this.width - this.fixed - 1; } word(word) { const size = word.length + 1; if (word.length > this.wordRoom()) { // A word with bytes is stopped while it is read; only an empty one // gets here, when -s leaves no room after the command. this.fail(word.length > 0 ? LINE_TOO_LONG : NO_ROOM); return; } if (this.held.length > 0 && (this.fixed + this.heldBytes + size > this.width || this.base.length + this.held.length >= this.mostWords)) { if (this.lines > 0 || (this.strict && this.group > 0)) { this.stop(LIST_TOO_LONG); return; } this.flush(); if (this.halted()) return; } this.held.push(word); this.heldBytes += size; if (this.group > 0 && this.held.length === this.group) this.flush(); } lineDone() { if (this.lines > 0 && this.held.length > 0) { this.linesHeld += 1; if (this.linesHeld === this.lines) this.flush(); } } fail(text) { if (this.held.length > 0 && !this.whole) this.flush(); this.stop(text); } close() { if (this.held.length > 0 || (this.runEmpty && !this.shipped)) this.flush(); this.ended = true; } } /** * Runs the command once per -I line, the line put in place of every * occurrence of the string in each initial argument (not the name). * * Measured on GNU 4.10: the first line must fit -s on its own and every * later one beside the command name, else `argument line too long`; the * name must fit (`cannot fit single argument`); an argument whose filled-in * text, with two bytes more, exceeds -s is `command too long`, as is any * initial argument when the string is empty; and the whole line must fit, * else `argument list too long`. */ class Filler extends Collector { template; later = false; constructor(base, width, flags, template) { super(base, width, flags); this.template = template; } wordRoom() { const taken = this.later ? (this.base[0]?.length ?? 0) + 1 : 0; return this.width - taken - 1; } word(word) { const name = this.base[0] ?? new Uint8Array(); // The limit is met while bytes arrive, so an empty line passes (GNU 4.10 // runs `-I{} -s 5 echo` on a later empty line). if (word.length > 0 && word.length > this.wordRoom()) { this.stop(LINE_TOO_LONG); return; } this.later = true; if (name.length + 1 > this.width) { this.stop(NO_ROOM); return; } let used = name.length + 1; const line = [name]; for (const part of this.base.slice(1)) { if (this.template.length === 0) { this.stop(TEMPLATE_TOO_LONG); return; } // The filled-in size is counted before the text is built, so a long // line in a long template costs no more than -s. const size = part.length + this.occurrences(part) * (word.length - this.template.length); if (size + 2 > this.width) { this.stop(TEMPLATE_TOO_LONG); return; } used += size + 1; if (used > this.width) { this.stop(LIST_TOO_LONG); return; } line.push(beforeNul(this.fill(part, word))); } this.ship(line); } occurrences(part) { let found = 0; let from = 0; for (;;) { const at = findBytes(part, this.template, from); if (at < 0) return found; found += 1; from = at + this.template.length; } } fill(part, word) { // Each occurrence, left to right, never overlapping: GNU 4.10 turns // `aaa` into `Za` for `-I aa`. const pieces = []; let from = 0; for (;;) { const at = findBytes(part, this.template, from); if (at < 0) break; pieces.push(part.subarray(from, at), word); from = at + this.template.length; } if (pieces.length === 0) return part; pieces.push(part.subarray(from)); return concat(pieces); } } /** * Splits input into words the way xargs does without -0 or -d. * * Blanks and newlines separate words; '...' and "..." keep blanks and take * no escapes; a backslash outside them takes the next byte as it is, a * newline included. A newline inside quotes is an unmatched quote; so is the * end of input inside them, but only once the word holds a byte: GNU 4.10 * runs nothing and exits 0 on a lone `'`. A word cut off by the end of input * is dropped when it holds no byte. * * A line that ends in a blank goes on into the next one, which -L counts * with it, and so does a line with no word on it (GNU 4.10 runs `a b` once * for `a <newline><newline>b` with -L1). The -E word ends the input wherever * a blank or newline closes it, but cut off by the end of input only as the * first word of its line (GNU 4.10 stops at `a<newline>STOP` and passes * `a STOP` on). With -I a whole line is one word: blanks inside it stay, * leading ones go. */ class QuotedReader { sink; endWord; wholeLines; word = []; begun = false; quote = null; escaped = false; previous = -1; lineOpen = false; wordOnLine = false; warned = false; constructor(sink, endWord, wholeLines) { this.sink = sink; this.endWord = endWord; this.wholeLines = wholeLines; } keep(byte) { if (byte === 0 && !this.warned) { this.warned = true; this.sink.note(NUL_IN_INPUT); } this.word.push(byte); this.begun = true; this.checkRoom(); } checkRoom() { if (this.word.length > this.sink.wordRoom()) this.sink.fail(LINE_TOO_LONG); } /** * Reads from `at` until the data runs out, xargs stops, or a command is * built; returns where it stopped. */ feed(data, at = 0) { const sink = this.sink; while (at < data.length && !sink.halted() && !sink.holding()) { const next = data[at] ?? 0; if (this.escaped) { this.escaped = false; this.previous = next; this.keep(next); at += 1; continue; } const closing = this.quote === null ? undefined : QUOTED_RUN.get(this.quote); if (this.quote !== null && closing !== undefined) { const after = this.copyRun(data, at, closing); if (after > at) { at = after; continue; } const byte = data[at] ?? 0; at += 1; this.previous = byte; if (byte === this.quote) this.quote = null; else if (byte === 0x0a) sink.fail(unclosed(this.quote)); else this.keep(byte); continue; } if (!this.begun && SKIPPED_BEFORE_WORD.has(next)) { at += 1; continue; } const after = this.copyRun(data, at, this.wholeLines && this.begun ? LINE_RUN : WORD_RUN); if (after > at) { this.begun = true; at = after; continue; } const byte = data[at] ?? 0; at += 1; if (byte === 0x5c) { this.escaped = true; } else if (byte === 0x27 || byte === 0x22) { this.quote = byte; this.begun = true; } else if (byte === 0x0a) { const carried = !this.wholeLines && (this.previous === 0x20 || this.previous === 0x09); if (this.begun) this.finishWord(false); const ends = !carried && this.wordOnLine; this.wordOnLine = false; if (ends && !sink.halted()) { this.lineOpen = false; sink.lineDone(); } } else if (byte === 0x20 || byte === 0x09) { if (this.begun && !this.wholeLines) this.finishWord(false); } else { this.keep(byte); } this.previous = byte; } return at; } copyRun(data, at, run) { // Copies the bytes from `at` that need no decision, but no more than one // past what the word may hold; returns where the next one starts. const last = Math.min(data.length, at + Math.max(1, this.sink.wordRoom() - this.word.length + 1)); let end = at; while (end < last && !run.has(data[end] ?? 0)) end += 1; if (end === at) return at; for (let index = at; index < end; index += 1) this.word.push(data[index] ?? 0); this.previous = data[end - 1] ?? 0; this.checkRoom(); return end; } finishWord(cutOff) { const word = Uint8Array.from(this.word); this.word = []; this.begun = false; const endWord = this.endWord; if (endWord !== null && !(cutOff && this.lineOpen)) { const text = beforeNul(word); if (text.length === endWord.length && text.every((byte, index) => byte === endWord[index])) { this.sink.close(); return; } } this.lineOpen = true; this.wordOnLine = true; this.sink.word(word); } /** * Finishes the input; false when it paused with a command built from the * last word, to be called again once that has been run. */ end() { const sink = this.sink; if (sink.halted()) return true; if (this.word.length > 0) { if (this.quote !== null) { sink.fail(unclosed(this.quote)); return true; } this.finishWord(true); if (sink.holding() && !sink.halted()) return false; } if (!sink.halted()) sink.close(); return true; } } /** * Splits input at one byte (-0, -d): every piece is a word as it stands, an * empty one included, and counts as a line for -L; a last piece with no * separator after it is a word too when it holds a byte. */ class SplitReader { sink; separator; piece = []; pieceBytes = 0; constructor(sink, separator) { this.sink = sink; this.separator = separator; } /** * Reads from `at` until the data runs out, xargs stops, or a command is * built; returns where it stopped. */ feed(data, at = 0) { while (!this.sink.halted() && !this.sink.holding()) { const cut = data.indexOf(this.separator, at); if (!this.take(data, at, cut < 0 ? data.length : cut)) return data.length; if (cut < 0) return data.length; at = cut + 1; this.handOver(); } return at; } take(data, start, stop) { // Copies no more than one byte past what the word may hold, so an item // that never ends stops at the limit. const room = this.sink.wordRoom(); const part = data.slice(start, Math.min(stop, start + Math.max(1, room - this.pieceBytes + 1))); if (part.length > 0) { this.piece.push(part); this.pieceBytes += part.length; } if (this.pieceBytes > 0 && this.pieceBytes > room) { this.sink.fail(LINE_TOO_LONG); return false; } return true; } handOver() { const word = concat(this.piece); this.piece = []; this.pieceBytes = 0; this.sink.word(word); if (!this.sink.halted()) this.sink.lineDone(); } /** * Finishes the input; false when it paused with a command built from the * last piece, to be called again once that has been run. */ end() { if (this.pieceBytes > 0 && !this.sink.halted()) { this.handOver(); if (this.sink.holding() && !this.sink.halted()) return false; } if (!this.sink.halted()) this.sink.close(); return true; } } function assemble(plan, base, room) { const sink = plan.template !== null ? new Filler(base, plan.width, plan.flags, encodeText(plan.template)) : new Packer(base, plan.width, plan.flags, plan.group, plan.lines, // GNU 4.10 tries at most room / 8 - 2 words in one line (with -s // 2095097 and 300000 empty -0 items it tries 261885, then halves // what exec refuses; that retry is not modelled). Math.floor(room / 8) - 2); if (plan.separator !== null) return [sink, new SplitReader(sink, plan.separator)]; const endWord = plan.endWord !== null ? encodeText(plan.endWord) : null; return [sink, new QuotedReader(sink, endWord, plan.template !== null)]; } function trace(line) { return `${line.map((word) => shellQuote(decodeText(word))).join(' ')}\n`; } /** GNU's report of a command xargs finds nothing to run for. */ export function xargsMissing(name) { return `xargs: ${name}: No such file or directory\n`; } /** * Run one command into its pipe, keeping its stderr and status when cut * short; one cut short before answering died of SIGPIPE. */ async function drainChild(child, output, pipe) { let io; try { io = await child; await pump(pipe, Channel.STDERR, io.stderr); await pump(pipe, Channel.STDOUT, io.stdout); } finally { output.stderr = await pipe.snapshot(Channel.STDERR); output.exitCode = io === undefined ? SIGPIPE_STATUS : io.exitCode; } } /** * Run the builder's command lines as they arrive, at most `procs` at a time. * * Messages keep their place among the runs. A command nobody provides stops * xargs with GNU's 127 before it runs, and one exiting 255 stops it with 124 * once it has run, each after GNU's diagnostic; the commands already running * finish. Parallel mode, and a slot variable, give every command a fork of * the session, even a single command, so one cannot see another's * variables, and each drains inside its fork, since a stream can still read * the ambient session. Where the async context cannot keep concurrent forks * apart (a browser without AsyncLocalStorage) the lines run one at a time, * each in its own fork. The results come back in input order, which is the * order their output is written in. */ async function* runLines(executeFn, events, context, procs, opts) { const session = context.session; const slotVar = opts.slotVar ?? null; const registry = opts.registry ?? null; let stop = null; const stopping = new AbortController(); // Read through a call: another command may stop the run during an await. const stopped = () => stop !== null; const forked = procs !== 1 || slotVar !== null; const taken = new Set(); const stdin = opts.stdin == null ? new Uint8Array() : new SharedStdin(opts.stdin); // xargs execs its command, so a builtin that is also a program answers // as the program. const active = new Map(); const run = async (words, output, pipe, signal) => { const line = shellJoin(words); if (!forked) { await runAsProgram(session, () => drainChild(executeFn(line, { sessionId: session.sessionId, context, stdin, signal, sink: pipe }), output, pipe)); return; } let slot = 0; while (taken.has(slot)) slot += 1; taken.add(slot); const childEvaluation = context.fork(); const child = childEvaluation.session; if (slotVar !== null) { child.vars = { ...child.vars, ...varsFromEnv({ [slotVar]: String(slot) }) }; } try { await runWithEvaluation(childEvaluation, () => runAsProgram(child, () => drainChild(executeFn(line, { sessionId: child.sessionId, context: childEvaluation, session: child, stdin, signal, sink: pipe, }), output, pipe))); return; } finally { taken.delete(slot); } }; const invoke = (event, signal) => { const slot = []; if (typeof event === 'string') return Promise.resolve({ results: [new IOResult({ stderr: encodeText(event) })] }); const words = event.map(decodeText); const name = words[0] ?? ''; if (opts.trace === true) slot.push(new IOResult({ stderr: encodeText(trace(event)) })); if (registry !== null && !execs(name, session, registry)) { slot.push(new IOResult({ stderr: encodeText(xargsMissing(name)), exitCode: NOT_FOUND_STATUS })); stop = NOT_FOUND_STATUS; stopping.abort(); return Promise.resolve({ results: slot }); } const pipe = new PipeConsole(); const io = new IOResult({ stdout: pipe.stream() }); slot.push(io); const task = (async () => { try { await run(words, io, pipe, signal); if (io.exitCode === 255 && !stopped()) { io.stderr = (await io.merge(new IOResult({ stderr: encodeText('xargs: ' + name + ': exited with status 255; aborting\n'), }))).stderr; stop = 124; stopping.abort(); } } catch (error) { stop ??= 1; pipe.end(error); return error; } finally { pipe.end(); } })(); active.set(task, [io, pipe]); return Promise.resolve({ results: slot, task }); }; async function* admitted(signal) { const source = events(mergeSignals(signal, stopping.signal) ?? signal); try { while (!stopped()) { const next = await source.next(); if (next.done === true) return; if (!stopped()) yield next.value; } } catch (error) { if (!stopped()) throw error; } finally { await source.return(undefined); } } const width = procs !== 1 && asyncContextIsolatesTasks ? procs : 1; try { for await (const job of boundedMapStream(admitted, invoke, width, context.frame.abortSignal ?? undefined)) { yield [job.results, stop]; if (job.task !== undefined) { await job.task; active.delete(job.task); } } } finally { for (const [, pipe] of active.values()) pipe.closeReader(); for (const error of await Promise.all(active.keys())) { if (error === undefined || error instanceof PipeClosed) continue; if (!(error instanceof DOMException && error.name === 'AbortError')) console.debug('xargs command raised at teardown:', error); } for (const [io] of active.values()) opts.torn.push(io); } } /** * Run a command with words read from stdin (GNU xargs). * * The words are appended to the initial arguments, or with -I each input * line takes the place of the string in them. Options act in the order * given: -I, -L and -n cancel each other with GNU's warning, and --help or * --version answers where it stands. The spec declares every option GNU's * table has, so an abbreviated long option resolves exactly as GNU's does. * * The limits are GNU's on a Linux system with an 8 MiB stack: the * environment counts against ARG_MAX (2 MiB) as GNU measures it, and a * command line holds 128 KiB unless -s says otherwise. There is no * terminal, so -p and -o fail the way GNU does without one. * * GNU xargs execs the command directly, so every input word must reach it * as exactly one argv token. The inner line is built with shellJoin: a * plain join would be re-parsed by the shell, splitting words with * whitespace and executing $(...) found in input. */ export async function handleXargs(executeFn, args, context, stdin, options = {}) { const session = context.session; const parse = parseShellOptions(SHELL_SPECS.xargs, args); let environment = 0; for (const [name, value] of Object.entries(envSnapshot(session))) { environment += encodeText(`${name}=${value}`).length + 1; } const room = EXEC_BYTES - environment - UNUSED_BYTES; const plan = new Plan(room, UNUSED_BYTES + environment >= EXEC_BYTES); plan.width = Math.min(STANDARD_WIDTH, room); try { for (const [name, value] of parse.given) { if (name === 'help' || name === 'version') return answer(name, plan.notes); plan.take(name, typeof value === 'string' ? value : null); } } catch (error) { if (!(error instanceof UsageError)) throw error; return refusal(plan.notes + error.message, error.exitCode); } const notes = encodeText(plan.notes); if (parse.invalid !== null) { const [stderr, code] = parse.candidates.length > 0 ? ambiguousOptionError('xargs', parse.invalid, parse.candidates) : unknownOptionError('xargs', parse.invalid); return refusal(concat([notes, stderr]), code); } if (parse.unexpectedValue !== null) { const [stderr, code] = unexpectedValueError('xargs', parse.unexpectedValue); return refusal(concat([notes, stderr]), code); } if (parse.needsValue !== null) { const [stderr, code] = missingValueError('xargs', parse.needsValue); return refusal(concat([notes, stderr]), code); } if (plan.endWord !== null && plan.separator !== null) { plan.notes += 'xargs: warning: the -E option has no effect if -0 or -d is used.\n\n'; } if (plan.crowded) return refusal(plan.notes + CROWDED); let data; let childStdin = null; if (plan.source === '-') { data = stdin ?? new Uint8Array(); } else { try { if (options.dispatch === undefined || options.dispatch === null) { throw enoent(plan.source); } data = await readScriptBytes(options.dispatch, plan.source, session.cwd); } catch (err) { const strerror = fsStrerror(err); if (strerror === null) throw err; return refusal(`${plan.notes}xargs: Cannot open input file '${quoteText(plan.source)}': ${strerror}\n`); } childStdin = stdin; } if (plan.flags.has('show-limits')) plan.notes += limitsReport(environment, room, plan.width); const command = parse.operands.length > 0 ? parse.operands : ['echo']; const [sink, reader] = assemble(plan, command.map((word) => encodeText(word)), room); async function* events(signal) { const source = chunks(data, signal); try { sink.begin(); while (!sink.halted()) { const part = await source.next(); // Each command goes to the runner as soon as it is built, before the // rest of the chunk is read. if (part.done === true) { let finished = false; while (!finished) { finished = reader.end(); yield* sink.drain(); } break; } let at = 0; for (;;) { at = reader.feed(part.value, at); yield* sink.drain(); if (at >= part.value.length || sink.halted()) break; } } } finally { await source.return?.(undefined); } } const merged = new IOResult(plan.notes === '' ? {} : { stderr: encodeText(plan.notes) }); const node = new ExecutionNode({ command: 'xargs' }); async function* output() { let stopped = null; let failed = false; const torn = []; async function record(io) { if (io.exitCode === 255 && stopped === null) stopped = 124; merged.stderr = (await merged.merge(new IOResult({ stderr: io.stderr }))).stderr; return io.exitCode !== 0; } try { for await (const [ios, stop] of runLines(executeFn, events, context, plan.jobs, { trace: plan.flags.has('t'), slotVar: plan.slotVar, registry: options.registry ?? null, stdin: childStdin, torn, })) { stopped = stop; for (const io of ios) { yield* chunks(io.stdout ?? new Uint8Array()); failed = (await record(io)) || failed; } } } finally { for (const io of torn) failed = (await record(io)) || failed; // A reader that stops early still sees the runs it reached. if (stopped !== null) merged.exitCode = stopped; else if (sink.failure !== null) { const [text, code] = sink.failure; merged.stderr = (await merged.merge(new IOResult({ stderr: encodeText(text) }))).stderr; // A command that failed outranks xargs's own error: GNU 4.10 exits // 123 on `X1 'c` when X1 fails. merged.exitCode = failed ? 123 : code; } else merged.exitCode = failed ? 123 : 0; node.exitCode = merged.exitCode; } } return [output(), merged, node]; } /** The `xargs` arm. */ export async function xargsBuiltin(call) { return handleXargs(call.executeFn, [...call.argv.args], call.context, call.stdin, { dispatch: call.dispatch, registry: call.registry, }); } //# sourceMappingURL=xargs.js.map