UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

385 lines • 15.3 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 { CommandTimeoutError } from "../../../errors/types.js"; import { memoryRoom } from "../../../io/memory.js"; import { materialize } from "../../../io/types.js"; import { concat } from "../../../utils/bytes.js"; import { boundSystemClock } from "../../../utils/clock.js"; import { mainFilename } from "../execution.js"; import { DEFAULT_PROG, LIMITS, POOL_WORKERS } from "./constants.js"; import { displayError } from "./errors.js"; import { MontyFs } from "./fs.js"; import { loadMontyModule, } from "./loader.js"; const INTERRUPTED = Symbol('interrupted'); const ENC = new TextEncoder(); /** * Hard-stop one monty worker. The engine offers no cancel: feedRun * has no signal and session.close() waits for the in-flight turn * (probed live), so the only way to stop a busy guest is to kill its * worker process — which poisons the session (the pool discards and * replaces the worker) and settles the pending feedRun. The pid is * only readable while the session is idle, so callers capture it * before feeding. Python's runtime does the same reclaim: cancelling * a feed leaves the worker running, so its `_kill_worker` SIGKILLs it * on cancellation exactly like this. */ function killWorker(pid) { if (pid === undefined) return; const proc = globalThis .process; if (typeof proc?.kill !== 'function') return; try { proc.kill(pid, 'SIGKILL'); } catch { // the worker may already have exited; nothing left to reclaim } } /** * The one pool of monty workers every workspace in the process runs on, so * an idle workspace holds no worker of its own. The first runtime that needs * it opens it and the last to let go closes it; `POOL_WORKERS` run at once * and a run past them waits. Each run still checks out with its own limits. * Mirrors Python's `_SharedPool`. */ class SharedPool { users = 0; opened = null; /** The pool, opened by the first caller; every acquire is released once. */ async acquire() { this.users += 1; const opened = (this.opened ??= SharedPool.open()); try { return await opened; } catch (err) { if (this.opened === opened) this.opened = null; await this.release(); throw err; } } static async open() { const module = await loadMontyModule(); return module.Monty.create({ maxProcesses: POOL_WORKERS }); } /** Let go of the pool, closing it once no runtime holds it. */ async release() { this.users -= 1; const opened = this.opened; if (this.users > 0 || opened === null) return; this.opened = null; let pool; try { pool = await opened; } catch (err) { console.debug('monty: the pool being opened at close failed', err); return; } await pool.close(); } } const sharedPool = new SharedPool(); /** * Runs on pooled monty workers: the process's shared pool, or one of its own * for a run nested in another, which must not queue behind its parent's * worker. */ export class MontyExecution { shared; name = 'monty'; limits; module = null; pool = null; poolPromise = null; /** * Args: * maxMemory: the heap one run may use, in bytes; null for no cap. * shared: run on the process's shared pool. */ constructor(maxMemory = null, shared = false) { this.shared = shared; this.limits = maxMemory === null ? LIMITS : { ...LIMITS, maxMemory }; } /** * Run one program on a pooled worker. * * @param program the source and argv to run. * @param ctx the run's context: working directory, environment, signal * and limit. * @param files the run's file adapter; null outside a workspace. * @param output receives stdout as it is printed; absent returns it in * the result. * @param errors the same for stderr. * @param externalLookup the host functions the program may call by name. */ async run(program, ctx, files, output, errors, externalLookup = {}) { // Monty executes on its own worker process, so the event loop stays // live to observe the limit deadline and the kill signal; a trip // SIGKILLs the worker (see killWorker). Deadline -> exit 124 via // CommandTimeoutError, kill -> exit 1 like the local runtime. Both count // from before the checkout, which waits while every worker of the shared // pool is busy. const interruption = this.installInterruption(ctx.signal, ctx.timeoutSeconds); try { // A script is Monty's own script_name, which it keeps the last part of // for `__file__`, under the directory a feed starts in. const checkout = this.checkout(program.scriptPath === undefined ? undefined : mainFilename(program, ctx.cwd)); const session = await Promise.race([checkout, interruption.promise]); if (session === INTERRUPTED) { // A worker that arrives after the run was stopped goes straight back. void checkout .then((late) => late.close()) .catch((err) => { console.debug('monty: a checkout that arrived after its run stopped failed', err); }); return this.interrupted(interruption, ctx); } const workerPid = session.workerPid; const prints = new Prints(output, errors); try { // The prints are handed on inside the race, so a deadline or a kill // still stops a run whose reader holds its last writes back. The // kill ends the worker, not a host callback still at work (a scan // lists directory after directory), so the file calls stop too. const guestFiles = files?.withSignal(interruption.signal) ?? null; const run = this.feedOne(session, program, ctx, guestFiles, prints, externalLookup).then(async (outcome) => { await prints.end(); return outcome; }); const winner = await Promise.race([run, interruption.promise]); if (winner !== INTERRUPTED) { const [exitCode, note] = winner; const [stdout, stderr] = prints.streams(); return { stdout, stderr: note === '' ? stderr : concat([stderr ?? new Uint8Array(), ENC.encode(note)]), exitCode, }; } prints.stop(); run.catch(() => undefined); killWorker(workerPid); return this.interrupted(interruption, ctx); } finally { await session.close(); } } finally { interruption.dispose(); } } // How a stopped run ends: past its deadline it throws, killed it exits 1. interrupted(interruption, ctx) { if (interruption.timedOut.value && ctx.timeoutSeconds !== undefined) { throw new CommandTimeoutError(this.name, ctx.timeoutSeconds); } return { stdout: new Uint8Array(), stderr: null, exitCode: 1 }; } installInterruption(signal, timeoutSeconds) { const timedOut = { value: false }; let timer = null; let removeAbort = null; const stop = new AbortController(); const promise = new Promise((resolve) => { const interrupt = () => { stop.abort(); resolve(INTERRUPTED); }; if (timeoutSeconds !== undefined && timeoutSeconds > 0) { timer = setTimeout(() => { timedOut.value = true; interrupt(); }, timeoutSeconds * 1000); } if (signal !== undefined) { if (signal.aborted) { interrupt(); return; } const onAbort = () => { interrupt(); }; signal.addEventListener('abort', onAbort, { once: true }); removeAbort = () => { signal.removeEventListener('abort', onAbort); }; } }); return { promise, timedOut, signal: stop.signal, dispose: () => { if (timer !== null) clearTimeout(timer); if (removeAbort !== null) removeAbort(); }, }; } async close() { // A pool still being opened must be awaited before teardown, or its // workers outlive the runtime that asked for them. One that fails to // open has nothing to close; the command that asked already reported it. if (this.pool === null && this.poolPromise !== null) { try { this.pool = await this.poolPromise; } catch (err) { console.debug('monty: the pool being opened at close failed', err); } } this.poolPromise = null; if (this.pool !== null) { const pool = this.pool; this.pool = null; if (this.shared) await sharedPool.release(); else await pool.close(); } } async ensurePool() { if (this.pool !== null) return this.pool; const opening = (this.poolPromise ??= this.loadPool()); try { this.pool = await opening; } catch (err) { if (this.poolPromise === opening) this.poolPromise = null; throw err; } return this.pool; } async loadPool() { if (this.shared) return sharedPool.acquire(); const module = await this.loadModule(); return module.Monty.create(); } async loadModule() { this.module ??= await loadMontyModule(); return this.module; } async checkout(scriptName) { const pool = await this.ensurePool(); const room = memoryRoom(); const own = this.limits.maxMemory; return pool.checkout({ ...(scriptName === undefined ? {} : { scriptName }), limits: room === null ? this.limits : { ...this.limits, maxMemory: own === undefined ? room : Math.min(own, room) }, assertMessageAnnotations: false, // A workspace's system_time answers the wall clocks (MontyFs). osPolicy: boundSystemClock() === null ? { sleepSystemMax: Infinity } : { sleepSystemMax: Infinity, datetime: 'call_host' }, }); } /** Feed the program to a worker; its status and what to add to its stderr. */ async feedOne(session, program, ctx, files, prints, externalLookup) { const module = await this.loadModule(); // Monty has no `sys.stdin`, so piped bytes ride in as a global the // same way argv does: raw bytes, null when nothing was piped. const stdin = program.stdin === null ? null : await materialize(program.stdin); const options = { // argv[0] is the program's own name when the caller has one (a // CLI install's head word), else the interpreter's placeholder. // `??`, not `||`: "" is CPython's own argv[0] for a program piped in // with no operand, so an empty prog is an answer. inputs: { argv: [program.prog ?? DEFAULT_PROG, ...program.args], stdin }, cwd: ctx.cwd.virtual, printCallback: prints.write, os: new MontyFs(module, ctx.env ?? {}, files).handle, ...(Object.keys(externalLookup).length > 0 ? { externalLookup: { ...externalLookup } } : {}), }; try { await session.feedRun(program.code, options); } catch (caught) { if (caught instanceof module.MontySyntaxError || caught instanceof module.MontyRuntimeError) { const inner = caught.exception; if (inner?.typeName === 'SystemExit') { return inner.message ? [1, inner.message + '\n'] : [0, '']; } return [1, displayError(caught) + '\n']; } if (caught instanceof module.MontyCrashedError) { return [1, `${this.name}: worker ${caught.timedOut ? 'timed out' : 'crashed'}\n`]; } throw caught; } return [0, '']; } } /** * What a program prints, in the order it printed it: each write handed to * its stream's sink as it comes, or kept for the result where the stream * has none. * * Monty calls back without waiting, so a write for a sink is chained * behind the one before it; the first that fails stops the rest, and * `end` reports it. */ class Prints { sinks; kept = { stdout: [], stderr: [] }; writing = Promise.resolve(); failure = null; stopped = false; /** * @param output where stdout goes; absent keeps it. * @param errors where stderr goes; absent keeps it. */ constructor(output, errors) { this.sinks = { stdout: output, stderr: errors }; } write = (stream, text) => { const sink = this.sinks[stream]; if (sink === undefined) { this.kept[stream].push(text); return; } this.writing = this.writing .then(() => (this.failure === null && !this.stopped ? sink(ENC.encode(text)) : undefined)) .catch((error) => { this.failure ??= { error }; }); }; /** Skip the writes still queued once the run is interrupted. */ stop() { this.stopped = true; } /** Wait until every write has been handed on. */ async end() { await this.writing; if (this.failure !== null) throw this.failure.error; } /** The stdout and stderr kept for the result, null for no stderr. */ streams() { const err = this.kept.stderr; return [ENC.encode(this.kept.stdout.join('')), err.length > 0 ? ENC.encode(err.join('')) : null]; } } //# sourceMappingURL=execution.js.map