UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

463 lines • 21.9 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 { runWithEvaluation } from "../../context/session_context.js"; import { CAPACITY } from "../../io/pipe.js"; import { asyncChain, closeQuietly, discardIo, discardStreams } from "../../io/stream.js"; import { IOResult, materialize } from "../../io/types.js"; import { encodeText } from "../../shell/bytes.js"; import { CallStack } from "../../shell/call_stack.js"; import { JobOutput } from "../../shell/console/index.js"; import { PipeConsole } from "../../shell/console/pipe.js"; import { Channel } from "../../shell/console/types.js"; import { FORK_FAILED, FORK_FAILED_STATUS, SIGPIPE_STATUS } from "../../shell/constants.js"; import { Recorder } from "../../shell/descriptors.js"; import { ExitSignal, PipeClosed, ReturnSignal } from "../../shell/errors.js"; import { JobWaits } from "../../shell/job_table/index.js"; import { simpleCommand } from "../../shell/node_kind.js"; import { NodeType as NT } from "../../shell/types.js"; import { PathSpec } from "../../types.js"; import { abortable, makeAbortError, mergeSignals } from "../../utils/abort.js"; import { asyncContextIsolatesTasks } from "../../utils/async_context.js"; import { concat } from "../../utils/bytes.js"; import { childContext } from "../evaluation.js"; import { ExecutionNode } from "../types.js"; import { divertStatement } from "./builtins/exec/index.js"; import { carried, ended, isUnwinding } from "./control.js"; import { handleBackground, pump } from "./jobs.js"; import { answerClosed, asWritten, carryStatus, errexitActs, fd0Binding, finishStatement, ignoringErrexit, land, landLate, recording, recordJobs, recordStatus, statementOutput, statementStdin, writerForks, } from "./statement.js"; import { endShell, errTrapArmed, inheritTraps, runErrTrap, runExitTrap } from "./traps.js"; export async function handlePipe(executeNode, commands, stderrFlags, context, stdin = null, callStack = null, signal, processes, // Each stage is a child shell, which runs its own EXIT action through // this when it ends. executeFn = null, bufferBytes = CAPACITY, // Where the statement's output goes as it arrives; the last stage // streams into it instead of being collected first. sink) { const session = context.session; // Reassociated pipelines can enter here without executeNode resetting // the parent. An exemption belongs to the preceding statement only; // the caller applies this pipeline's own negation after it finishes. session.errexitImmune = false; const pipes = commands.map((_, i) => new PipeConsole(stderrFlags[i] === true, bufferBytes)); const ios = commands.map(() => new IOResult()); // A stage the top shell forks for a simple command is that command's // shell; one a child shell forks is a child of a child. const forked = callStack?.subshell === true; const childNodes = commands.map(() => new ExecutionNode()); const abort = new AbortController(); const parentSignal = mergeSignals(signal, context.frame.abortSignal); const onAbort = () => { abort.abort(parentSignal?.reason); for (const pipe of pipes) pipe.closeReader(); // The shell's own fd 0 outlives the line, as bash's does. if (stdin !== session.execStdin) void discardStreams(stdin); }; parentSignal?.addEventListener('abort', onAbort, { once: true }); if (parentSignal?.aborted === true) onAbort(); let failed = false; const tasks = []; const launch = (cmd, i) => { // Each segment is a child shell: bash forks one per stage. const childEvaluation = childContext(context); const child = childEvaluation.session; inheritTraps(child); child.terminalOutput = session.terminalOutput && i === commands.length - 1; childEvaluation.frame.abortSignal = mergeSignals(context.frame.abortSignal, abort.signal) ?? abort.signal; const output = pipes[i]; if (output === undefined) throw new Error('Missing pipeline segment'); const upstream = pipes[i - 1]; const input = i === 0 ? stdin : (upstream?.stream() ?? null); const run = async () => { let io = new IOResult(); let childExec = new ExecutionNode({ command: cmd.text }); const stageStack = (callStack ?? new CallStack()).fork(true, simpleCommand(cmd) ? null : true); // A job a stage before the last starts writes into the pipe, and // the reader sees end of input only once the job has closed it. const waits = i < commands.length - 1 ? new JobWaits(new JobOutput(output), new Set(stderrFlags[i] === true ? [Channel.STDOUT, Channel.STDERR] : [Channel.STDOUT])) : null; if (waits !== null) { child.jobOutput = waits.output; child.jobWaits = waits; } const rest = session.jobOutput ?? session.tty.jobs; try { const [stdout, result, execution] = await endShell(executeFn, child, input, stageStack, executeNode(cmd, childEvaluation, input, stageStack, { sink: output, signal: abort.signal, })); io = result; childExec = execution; await pump(output, Channel.STDOUT, stdout); await pump(output, Channel.STDERR, io.stderr); await waits?.join(rest); } catch (error) { if (error instanceof PipeClosed) { io.exitCode = SIGPIPE_STATUS; } else if (isUnwinding(error)) { // A stage is a subshell: whatever unwinds ends it there, a simple // command the top shell forked as that shell would. const unwound = ended(error, simpleCommand(cmd) && !forked); io.exitCode = unwound.exitCode; await pump(output, Channel.STDOUT, unwound.stdout); await pump(output, Channel.STDERR, unwound.stderr); await waits?.join(rest); } else { output.end(error); throw error; } } finally { upstream?.release(); if (input !== null && !(input instanceof Uint8Array)) await closeQuietly(input); output.end(); io.stderr = await output.snapshot(Channel.STDERR); ios[i] = io; childNodes[i] = childExec; if (failed) await discardIo(io); await child.closeInputs(); } }; const execute = () => asyncContextIsolatesTasks ? runWithEvaluation(childEvaluation, run) : run(); if (processes === undefined) return execute(); let process; try { process = processes.start({ sessionId: session.sessionId, command: cmd.text, cwd: PathSpec.fromStrPath(child.cwd), parentPid: session.processId, cancel: () => { abort.abort(); }, run: async () => { await execute(); return ios[i]?.exitCode ?? 0; }, limit: session.processes.max, }); } catch (error) { if (error.code === 'EAGAIN') throw new ExitSignal(FORK_FAILED_STATUS, encodeText(FORK_FAILED)); throw error; } child.processId = process.info.pid; return process.task.then(() => undefined); }; let lastStdout = null; try { // A stage the session cannot fork ends the pipeline: the stages // already started are aborted below, as bash kills the pipeline. commands.forEach((cmd, i) => { tasks.push(launch(cmd, i)); }); const completed = Promise.all(tasks); // Attach the rejection handler before reading the last segment: an // upstream failure must settle the pipeline even if nobody reads it. const last = pipes[pipes.length - 1]?.stream() ?? null; const reading = sink === undefined ? materialize(last) : pump(sink, Channel.STDOUT, last).then(() => null); const result = await abortable(Promise.all([reading, completed]), parentSignal); if (parentSignal?.aborted === true) throw makeAbortError(parentSignal); lastStdout = result[0]; } catch (error) { failed = true; throw error; } finally { parentSignal?.removeEventListener('abort', onAbort); // Completed segments may leave cache streams for background drains. // Python only cancels unfinished tasks here; aborting a successful // pipeline would also cancel those streams after ownership passed on. if (failed) abort.abort(); for (const pipe of pipes) pipe.closeReader(); const settled = Promise.allSettled(tasks); if (!failed) await settled; if (failed) { for (const io of ios) await discardIo(io); // The shell's own fd 0 outlives the line, as bash's does: the next // line reads on from it. await discardStreams(lastStdout, stdin === session.execStdin ? null : stdin); } } const lastIo = ios[ios.length - 1] ?? new IOResult(); // Parked for the boundary that closes this statement to claim as // `${PIPESTATUS[@]}`: the raw per-segment statuses, before pipefail // rewrites the pipeline's own. session.pipeStatusPending = ios.map((io) => io.exitCode); if (session.shellOptions.pipefail === true) { let rightmostFailure = 0; for (let k = ios.length - 1; k >= 0; k--) { const code = ios[k]?.exitCode ?? 0; if (code !== 0) { rightmostFailure = code; break; } } if (rightmostFailure !== 0) lastIo.exitCode = rightmostFailure; } const mergedStderrParts = []; for (let i = 0; i < ios.length; i++) { const io = ios[i]; const child = childNodes[i]; if (io === undefined || child === undefined) continue; child.exitCode = io.exitCode; const stderrBytes = await materialize(io.stderr); if (stderrBytes.byteLength > 0) mergedStderrParts.push(stderrBytes); } if (mergedStderrParts.length > 0) { lastIo.stderr = concat(mergedStderrParts); } const execNode = new ExecutionNode({ op: '|', exitCode: lastIo.exitCode, children: childNodes, }); return [lastStdout, lastIo, execNode]; } /** * Handle `&&` and `||`. The left command runs where `set -e` is ignored. * The right one is the list's own: the ERR action is armed as it starts * and answers its failure, which the statement holding the list then does * not. Mirrors Python's handle_connection. */ export async function handleConnection(executeNode, left, op, right, context, stdin = null, callStack = null, executeFn = null) { const session = context.session; const bound = fd0Binding(session); const [leftStdout, leftIo, leftExec] = await ignoringErrexit(session, () => executeNode(left, context, stdin, callStack)); const children = [leftExec]; const leftBytes = await finishStatement(leftStdout, leftIo, session, left); if ((op === NT.AND && leftIo.exitCode !== 0) || (op === NT.OR && leftIo.exitCode === 0)) { if (op === NT.AND) session.errexitImmune = true; carryStatus(session); return [leftBytes, leftIo, new ExecutionNode({ op, exitCode: leftIo.exitCode, children })]; } let rightStdout; let rightIo; let rightExec; const armed = errTrapArmed(session); try { ; [rightStdout, rightIo, rightExec] = await executeNode(right, context, statementStdin(session, stdin, bound), callStack); } catch (err) { if (isUnwinding(err)) throw await carried(err, leftBytes, leftIo); throw err; } children.push(rightExec); // The right command closes here, so its ERR action reads its status and // `${PIPESTATUS[@]}`; the list's boundary claims them again once the // action is done. const rightBytes = await finishStatement(rightStdout, rightIo, session, right); let merged = await leftIo.merge(rightIo); const outputs = [leftBytes, rightBytes]; try { merged = await land(await runErrTrap(executeFn, right, rightIo.exitCode, session, armed, stdin, callStack, rightExec), null, outputs, merged); } catch (err) { if (isUnwinding(err)) throw await carried(err, asyncChain(outputs), merged); throw err; } carryStatus(session); const combined = asyncChain(outputs); return [ combined, merged, new ExecutionNode({ op: op ?? ';', exitCode: merged.exitCode, children }), ]; } /** * End a subshell on an `exit` (or `${var:?}`), or the `return` of a * function it runs in, that left a statement or the ERR action answering * one: a subshell is its own shell, and the signal's status is the * subshell's. Mirrors Python's _subshell_ended. */ async function subshellEnded(err, recorder, session, sink, allStdout, mergedIo) { mergedIo = await land(await statementOutput(recorder, err.stdout, new IOResult(), session.terminal, sink), sink, allStdout, mergedIo); const status = ended(err).exitCode; mergedIo = await mergedIo.merge(new IOResult({ exitCode: status, stderr: err.stderr })); mergedIo.exitCode = status; recordStatus(session, status); return [mergedIo, new ExecutionNode({ command: '()', exitCode: status, stderr: err.stderr })]; } /** * Run a subshell's body in the child shell its caller made. * * `body` is ALL subshell children, including the `&` tokens that mark * background statements (named-only lists would run `a & b` * synchronously and never set `$!`). Background jobs live in the * subshell's private `jobTable` (bash forks: the parent's table never * sees them), and `executeNode` is bound to that same table so * `wait`/`kill`/`jobs` inside the body resolve against it. */ export async function handleSubshell(executeNode, body, context, stdin = null, callStack = null, jobTable = null, // The dispatcher, so a subshell honors an `exec` redirect the way the // program loop does. A subshell is a child shell, so the redirect it // installs belongs to the child and is discarded when the body // ends. dispatch, // The line's hand-off and its ledger, for a background job to borrow. handed = null, decisions = null, // Where each statement's output goes as it finishes; the body is a shell // of its own, which routes what it wrote to its terminal through a copy, // so a program nested in it (`$( )`, `eval`) leaves that to it. sink = null, // Runs the subshell's own EXIT action as it ends. executeFn = null) { const session = context.session; inheritTraps(session); session.jobOutput = new JobOutput(session.jobOutput ?? session.tty.jobs); session.lineOpen = true; // A child shell: `shift` or `set --` in it leaves the caller's // parameters alone, and it runs in none of the caller's loops. callStack = (callStack ?? new CallStack()).fork(false, true); // The subshell's actions run as its own lines: their `wait` and `jobs` // see the subshell's jobs, not the caller's. const runAction = executeFn === null ? null : (action, opts) => executeFn(action, { ...opts, session, ...(jobTable === null ? {} : { jobTable }) }); const allStdout = []; let mergedIo = new IOResult(); let lastExec = new ExecutionNode({ command: '()', exitCode: 0 }); const bound = fd0Binding(session); let i = 0; while (i < body.length) { const child = body[i]; if (child?.isNamed !== true || child.type === NT.COMMENT) { i += 1; continue; } // `set -n` needs no arm here: `executeNode` refuses every node // while the option is on, so this loop simply runs a tail of // no-ops. The child owns the option, so it cannot leak to the parent. const isBg = body[i + 1]?.type === NT.BACKGROUND; const armed = errTrapArmed(session); if (isBg && jobTable !== null) { let launched; try { launched = await handleBackground(executeNode, child, null, context, jobTable, stdin, callStack, handed, decisions); } catch (err) { if (!(err instanceof ExitSignal)) throw err; // A job the subshell cannot fork ends the subshell only, its // status the subshell's. mergedIo = await mergedIo.merge(new IOResult({ exitCode: err.containedCode, stderr: err.stderr })); mergedIo.exitCode = err.containedCode; recordStatus(session, err.containedCode); lastExec = new ExecutionNode({ command: '()', exitCode: err.containedCode, stderr: err.stderr, }); break; } const [bgStdout, bgIo, bgExec] = launched; if (bgStdout !== null) allStdout.push(bgStdout); mergedIo = await mergedIo.merge(bgIo); // Seed $? for later body commands (mirrors program loop). recordStatus(session, bgIo.exitCode); lastExec = bgExec; i += 2; continue; } i += 1; const recorder = new Recorder(); const leave = recordJobs(session, recorder); let io; try { const childStdin = statementStdin(session, stdin, bound); let stdout; [stdout, io, lastExec] = await recording(recorder, () => executeNode(child, context, childStdin, callStack, { sink: recorder })); stdout = await finishStatement(stdout, io, session, child, lastExec); const written = await divertStatement(dispatch, session, await statementOutput(recorder, stdout, io, session.terminal, sink), io, child, lastExec.command ?? ''); try { mergedIo = await land(written, sink, allStdout, mergedIo); } catch (err) { // A closed pipe ends a forked writer alone, with 141, unless it answers // itself (`tee -p`); the subshell's own write ends it below. if (!(err instanceof PipeClosed)) throw err; if (sink === null || !(await answerClosed(io, (channel, data) => sink.emit(channel, data)))) { if (!writerForks(child, session, io)) throw err; io.exitCode = SIGPIPE_STATUS; } recordStatus(session, io.exitCode); } mergedIo = await mergedIo.merge(io); mergedIo = await land(await runErrTrap(runAction, child, io.exitCode, session, armed, stdin, callStack, lastExec), sink, allStdout, mergedIo); mergedIo = await landLate(recorder, leave, session.terminal, sink, allStdout, mergedIo); } catch (err) { // The subshell's own write met a closed pipe: SIGPIPE ends it. const sig = err instanceof PipeClosed ? new ExitSignal(SIGPIPE_STATUS) : err; if (!(sig instanceof ExitSignal || sig instanceof ReturnSignal)) { await recorder.close(); throw err; } ; [mergedIo, lastExec] = await subshellEnded(sig, recorder, session, sink, allStdout, mergedIo); // A closed pipe takes nothing more; anything else drains what jobs // wrote meanwhile. if (!(err instanceof PipeClosed)) mergedIo = await landLate(recorder, leave, session.terminal, sink, allStdout, mergedIo); break; } finally { leave(); } if (errexitActs(child, io.exitCode, session)) break; } const cleanup = await runExitTrap(runAction, session, mergedIo.exitCode, stdin, callStack); if (cleanup !== null) { mergedIo = await land(asWritten(await cleanup.materializeStdout(), await cleanup.materializeStderr()), sink, allStdout, mergedIo); mergedIo.exitCode = cleanup.exitCode; lastExec = new ExecutionNode({ command: '()', exitCode: cleanup.exitCode }); } const parts = allStdout.filter((part) => part !== null); if (parts.length === 1 && parts[0] !== undefined) { return [parts[0], mergedIo, lastExec]; } const combined = parts.length > 0 ? asyncChain(parts) : null; return [combined, mergedIo, lastExec]; } //# sourceMappingURL=pipes.js.map