@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
463 lines • 21.9 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 { 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