@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
909 lines • 39.6 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. =========
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
if (typeof path === "string" && /^\.\.?\//.test(path)) {
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
});
}
return path;
};
import { currentCaller, runWithCaller } from "../../../context/session_context.js";
import { Activity } from "../../../utils/activity.js";
import { asyncContextIsolatesTasks } from "../../../utils/async_context.js";
import { ConcurrencyLimiter } from "../../../concurrency/limiter.js";
import { CommandTimeoutError } from "../../../errors/types.js";
import { PythonRuntime } from "../base.js";
import { AsyncLineIterator } from "../../../io/async_line_iterator.js";
import { materialize } from "../../../io/types.js";
import { abortable } from "../../../utils/abort.js";
import { concat } from "../../../utils/bytes.js";
import { memoryLimit } from "../../config.js";
import { createPyodideInterrupter, } from "./interrupt.js";
import { noWorker, PyodideMemoryError, PyodideUnavailableError } from "./errors.js";
import { loadPyodideRuntime } from "./loader.js";
import { sealWorkerRealm } from "./seal.js";
import { RuntimeFiles } from "../../files.js";
import { createJournal } from "./fs/journal.js";
import { PyodideFs } from "./fs/fs.js";
import { PyodideExecution } from "./execution.js";
import { mainFilename } from "../execution.js";
import { unhonoredNotice } from "../flags.js";
import { classify } from "../../../errors/index.js";
import { resolvePath } from "../../../utils/path.js";
import { errnoError } from "./fs/errors.js";
import { decodeBase64, encodeBase64 } from "../../../utils/base64.js";
import { KEYS } from "./config.js";
import { PyodideWorkerClient } from "./worker/client.js";
import { CANNOT_EXECUTE_STATUS } from "../../../shell/constants.js";
import { boundSystemClock, systemNow } from "../../../utils/clock.js";
/**
* The run's streams on the main thread: its input taken as the guest
* reads it, as from a pipe, and its output handed to the sinks as it is
* written, or kept for the result where a stream has none.
*
* Args:
* stdin: the run's input.
* output: receives stdout as it is written.
* errors: the same for stderr.
*/
function hostStdio(stdin, output, errors) {
const input = stdin === null ? null : new AsyncLineIterator(stdin);
const kept = [[], []];
return {
kept,
read: (size, stop) => input === null ? Promise.resolve(new Uint8Array()) : abortable(input.read1(size), stop),
write: async (fd, bytes, stop) => {
const sink = fd === 1 ? output : errors;
if (sink === undefined) {
;
(fd === 1 ? kept[0] : kept[1]).push(bytes);
return true;
}
try {
await abortable(sink(bytes), stop);
return true;
}
catch (error) {
if (stop.aborted)
throw error;
// A reader that went away fails the guest's write with EPIPE.
console.debug(`pyodide: the reader went away: ${String(error)}`);
return false;
}
},
};
}
function bridgeBytes(value) {
return value instanceof Uint8Array ? value : new Uint8Array(value);
}
function bridgeStderr(value) {
const bytes = bridgeBytes(value);
return bytes.length > 0 ? bytes : null;
}
// The init switches this engine acts on, by CPython letter. The rest
// (-E, -I, -s, -S) only change how an interpreter *starts*, and this
// one is already running by the time a line is typed, so it reports
// them instead of pretending. See execution.py for what honoring the
// five below amounts to; -P takes the working directory off sys.path
// for the run, and no script's directory goes there in its place.
const HONORED_FLAGS = ['B', 'O', 'P', 'W', 'X', 'u'];
function decodeNoticeLines(notice) {
if (notice.length === 0)
return [];
return new TextDecoder()
.decode(notice)
.split('\n')
.filter((line) => line.length > 0);
}
function appendStderrLines(stderr, lines) {
if (lines.length === 0)
return stderr;
const extra = new TextEncoder().encode(lines.map((line) => line + '\n').join(''));
if (stderr === null)
return extra;
const out = new Uint8Array(stderr.length + extra.length);
out.set(stderr);
out.set(extra, stderr.length);
return out;
}
// Mount prefixes are normalized with a trailing slash; an Emscripten
// mountpoint is a directory path, so it carries none. The root prefix
// is the one that strips to nothing, and nothing is what makes it
// unmountable here: see `servable`.
function mountpointOf(prefix) {
return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
}
/**
* Whether this runtime can mount `prefix` at all.
*
* Everything but the workspace root can. `/` cannot: it is already
* MEMFS's own mount root, holding the interpreter's stdlib, and
* Emscripten answers EBUSY to a second mount there. Left to itself
* `mountpointOf` hands back the empty string, which mounts a detached
* pseudo-filesystem no path reaches, so the guest keeps reading and
* writing MEMFS and a write reports success while the VFS never
* sees it.
*
* Args:
* prefix: a slash-terminated mount prefix.
*/
function servable(prefix) {
return mountpointOf(prefix) !== '';
}
/**
* The mount prefix that covers pyodide's standard library, if the
* workspace has one, without its trailing slash: `/lib` itself, or
* anything under `/lib/python` (`/lib/python3.12`, `/lib/python312.zip`).
* Mounted there it would hide the library the interpreter imports from; a
* mount elsewhere under `/lib` (`/lib/app`) leaves it alone. Mirrors the
* wasi runtime's check in Python.
*
* Args:
* prefixes: the workspace's slash-terminated mount prefixes.
*/
function stdlibMount(prefixes) {
const found = prefixes.find((prefix) => prefix === '/lib/' || prefix.startsWith('/lib/python'));
return found === undefined ? null : found.slice(0, -1);
}
/**
* Drop every prefix nested inside another: only the shallowest of a
* nested pair earns an Emscripten mountpoint, and its lookups descend
* into the child through the dispatcher's merged readdir.
*
* Args:
* prefixes: slash-terminated mount prefixes.
*/
function maximalPrefixes(prefixes) {
const shallowFirst = [...prefixes].sort((a, b) => a.length - b.length);
const out = [];
for (const p of shallowFirst) {
if (out.some((kept) => p.startsWith(kept)))
continue;
out.push(p);
}
return out;
}
/**
* Rewrite top-level imports of denied packages so Pyodide's
* `loadPackagesFromImports` skips fetching them. The rewritten code is only
* fed to the auto-loader's import scanner — user code still runs unchanged,
* so the actual `import X` will hit any meta_path blocker installed in the
* Python bootstrap.
*
* Recognises:
* - `import X`, `import X.Y`, `import X as alias`
* - `from X import …`, `from X.Y import …`
* The match is line-scoped (`/m`) so multi-import lines like
* `import X, Y` are blanked out as a single statement.
*/
export function stripDeniedImports(code, denyPackages) {
if (denyPackages.size === 0)
return code;
return code.replace(/^[ \t]*(?:from|import)\s+([\w][\w.]*)[^\n]*/gm, (match, mod) => {
const top = mod.split('.')[0] ?? '';
if (!denyPackages.has(top))
return match;
return match.replace(mod, 'os');
});
}
export class PyodideRuntime extends PythonRuntime {
sync;
interruptBuffer;
name = 'pyodide';
versionSuffix = ' (pyodide)';
// The WASM guest's filesystem is the workspace-backed Emscripten FS,
// so its file calls pass the workspace gate; loader.ts gives the `js`
// module a null-prototype jsglobals, and sealNetwork (network.ts) makes
// Emscripten's WebSocket-backed sockets refuse. A trusted initializer
// that installs host capabilities makes this 'process'.
reach;
filesystem = ['read', 'write', 'list', 'stat', 'glob'];
pyodide = null;
guest = null;
initPromise = null;
bootstrapPromise = null;
disposeModule = null;
queue = Promise.resolve();
pending = new Activity();
limiter;
autoLoadFromImports;
bootstrapCode;
denyPackages;
home;
sysPath;
packages;
packageBaseUrl;
lockFileUrl;
files = null;
journal = createJournal();
deferred = new Map();
streams = new Set();
mounted = new Set();
// The directories made to hold a mountpoint, which an unmount takes
// back so a mount the session no longer sees leaves no name behind.
madeDirs = new Set();
mountedFilesystems = new Map();
// Prefixes this interpreter cannot mount, reported once per interpreter.
refused = new Set();
// Same rule for a sysPath glob that expanded to nothing. Seeding runs
// on every syncMounts pass, so without this a misconfigured pattern
// would warn on every line the agent types; and a pattern can start
// matching later (the wheels are written to the mount after boot),
// which is why the miss is reported rather than refused.
seedMissesReported = new Set();
seedNotices = [];
// The guest executes on this event loop, so only the watchdog-backed
// interrupt buffer can stop a busy loop (see interrupt.ts); null
// where SharedArrayBuffer/workers are unavailable (runs unbounded).
interrupter = null;
interrupterTried = false;
worker = null;
maxMemory;
syncFailures = [];
syncSkipped = 0;
realmSeal = null;
constructor(options = {}, sync, interruptBuffer) {
super(options, KEYS);
this.sync = sync;
this.interruptBuffer = interruptBuffer;
const config = this.config;
const { maxConcurrency = 1 } = config;
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1)
throw new Error('pyodide config: maxConcurrency must be a positive safe integer');
this.maxMemory = memoryLimit(config.maxMemory);
this.limiter = new ConcurrencyLimiter(asyncContextIsolatesTasks ? maxConcurrency : 1);
this.reach = config.initModule === undefined ? 'workspace' : 'process';
this.autoLoadFromImports = config.autoLoadFromImports ?? true;
this.bootstrapCode = config.bootstrapCode ?? null;
this.denyPackages = new Set(config.denyPackages ?? []);
this.home = config.home ?? null;
this.sysPath = config.sysPath ?? [];
this.packages = config.packages ?? [];
this.packageBaseUrl = config.packageBaseUrl ?? null;
this.lockFileUrl = config.lockFileUrl ?? null;
const denied = new Set(this.denyPackages);
const contradictory = this.packages.filter((name) => denied.has(name));
if (contradictory.length > 0) {
throw new Error(`pyodide config: ${contradictory.map((n) => `'${n}'`).join(', ')} ` +
`appears in both packages and denyPackages`);
}
}
/**
* Run one program on a fresh interpreter: in the worker for a run with
* files, in process for one without. In the worker the guest reads its
* input as it comes and its writes go to `output` and `errors` as it
* makes them; in process it reads its input whole and its output comes
* back in the result.
*
* @param _engine none; each run builds its own interpreter.
* @param program the source and argv to run.
* @param ctx the run's context.
* @param output receives stdout as the guest writes it.
* @param errors the same for stderr.
*/
async interpret(_engine, program, ctx, output, errors) {
const binding = this.binding;
const execute = async (runtime) => this.capped(runtime, await runtime.runOne(program, ctx, binding, output, errors));
if (this.sync === undefined && (ctx.processes?.depth ?? 0) > 0)
return this.withFreshRuntime(execute);
const caller = currentCaller();
return this.schedule(() => this.withPermit(() => runWithCaller(caller, () => this.withFreshRuntime(execute)), ctx.signal));
}
schedule(task) {
const release = this.pending.acquire();
return this.queue.then(task, task).finally(release);
}
enqueue(task) {
const next = this.queue.then(task, task);
this.queue = next.catch(() => undefined);
return next;
}
async withPermit(task, signal) {
const release = await this.limiter.acquire(signal);
try {
return await task();
}
finally {
release();
}
}
/**
* The MemoryError for an interpreter whose heap is past `maxMemory`, null
* while it is within it. The heap never shrinks, so its size when a run
* ends is the most the run used, however fast it was. A run in the worker
* is checked there, and on Node the worker is also watched while it runs
* (`watchMemory`).
*/
passedCap(runtime) {
const heap = runtime.pyodide?._module?.HEAP8;
if (this.maxMemory === null || heap === undefined || heap.length <= this.maxMemory)
return null;
const cap = this.config.maxMemory ?? this.maxMemory;
return new PyodideMemoryError(String(cap)).message;
}
/** A run's result, or a MemoryError when the run passed `maxMemory`. */
capped(runtime, result) {
const passed = this.passedCap(runtime);
if (passed === null)
return result;
return { stdout: result.stdout, stderr: new TextEncoder().encode(`${passed}\n`), exitCode: 1 };
}
createRuntime() {
return new PyodideRuntime({ config: this.config }, this.sync, this.interruptBuffer);
}
async withFreshRuntime(execute) {
const runtime = this.createRuntime();
let result;
try {
result = await execute(runtime);
}
catch (error) {
try {
await runtime.close();
}
catch (cleanupError) {
console.warn('pyodide runtime cleanup failed', cleanupError);
if (!(error instanceof Error))
throw new AggregateError([error, cleanupError], 'pyodide execution and cleanup failed');
Reflect.defineProperty(error, 'cause', {
configurable: true,
enumerable: false,
writable: true,
value: error.cause === undefined
? cleanupError
: new AggregateError([error.cause, cleanupError], 'pyodide execution and cleanup failed'),
});
}
throw error;
}
await runtime.close();
return result;
}
close() {
const pending = this.pending;
this.pending = new Activity();
return this.enqueue(async () => {
await pending.wait();
await this.closeOne();
});
}
async closeOne() {
await this.closeInterpreter();
}
async closeInterpreter() {
this.guest?.close();
this.guest = null;
const dispose = this.disposeModule;
this.disposeModule = null;
try {
await dispose?.();
}
finally {
this.bootstrapPromise = null;
this.pyodide = null;
const worker = this.worker;
this.worker = null;
this.initPromise = null;
this.files = null;
this.mounted.clear();
this.madeDirs.clear();
this.mountedFilesystems.clear();
try {
const client = await worker;
if (client instanceof PyodideWorkerClient)
client.close();
}
finally {
this.interrupter?.close();
this.interrupter = null;
this.interrupterTried = false;
}
}
}
async wireInterruptIfNeeded(pyodide) {
if (this.interrupterTried || pyodide.setInterruptBuffer === undefined)
return;
this.interrupter = await createPyodideInterrupter(this.interruptBuffer);
this.interrupterTried = true;
if (this.interrupter !== null)
pyodide.setInterruptBuffer(this.interrupter.view);
}
guardRequestedPaths(pyodide) {
const sync = this.sync;
if (sync === undefined)
return;
const fs = pyodide.FS;
const lookup = fs.lookupPath.bind(fs);
const stat = fs.stat.bind(fs);
let metadata = 0;
fs.stat = (path, nofollow) => {
metadata++;
try {
return stat(path, nofollow);
}
finally {
metadata--;
}
};
fs.lookupPath = (path, options) => {
const result = lookup(path, options);
const requested = path.startsWith('/') ? path : fs.cwd() + '/' + path;
const expected = resolvePath(options?.parent === true ? requested + '/..' : requested, '/');
const files = this.files;
if (metadata === 0 &&
result.path !== expected &&
files !== null &&
(servable(files.mountOf(requested) ?? '/') || servable(files.mountOf(result.path) ?? '/'))) {
try {
// Emscripten passes resolved nodes to filesystem callbacks. Check
// the requested spelling before it can act on an aliased node.
sync.checkPath(requested, options?.parent !== true && options?.follow === true);
}
catch (error) {
const condition = classify(error);
throw errnoError(fs, pyodide.ERRNO_CODES, condition ?? 'EIO');
}
}
return result;
};
}
async ensureLoaded() {
if (this.pyodide !== null) {
if (this.bootstrapPromise !== null)
await this.bootstrapPromise;
this.syncMounts(this.pyodide);
await this.wireInterruptIfNeeded(this.pyodide);
await this.sealRealmIfWorker(this.pyodide);
return this.pyodide;
}
this.initPromise ??= loadPyodideRuntime({
...(this.home !== null ? { home: this.home } : {}),
...(this.packageBaseUrl !== null ? { packageBaseUrl: this.packageBaseUrl } : {}),
...(this.lockFileUrl !== null ? { lockFileURL: this.lockFileUrl } : {}),
...(this.packages.length > 0 ? { packages: this.packages } : {}),
}).then(async (py) => {
const ref = this.config.initModule;
if (ref !== undefined) {
const module = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ ref)));
if (typeof module.default !== 'function') {
throw new TypeError('pyodide initModule must export a default initializer');
}
const dispose = await module.default(py);
if (dispose !== undefined && typeof dispose !== 'function') {
throw new TypeError('pyodide initModule must return a cleanup function or undefined');
}
this.disposeModule = dispose ?? null;
}
this.guardRequestedPaths(py);
return py;
});
this.pyodide = await this.initPromise;
if (this.bootstrapCode !== null) {
const code = this.bootstrapCode;
const py = this.pyodide;
this.bootstrapPromise = (async () => {
if (py.loadPackagesFromImports !== undefined) {
try {
await py.loadPackagesFromImports(code, { messageCallback: () => undefined });
}
catch {
// best-effort
}
}
await py.runPythonAsync(code);
})();
await this.bootstrapPromise;
}
this.syncMounts(this.pyodide);
await this.wireInterruptIfNeeded(this.pyodide);
await this.sealRealmIfWorker(this.pyodide);
return this.pyodide;
}
/**
* Seal the Node worker the guest runs in, once the interpreter and any
* trusted init have loaded. Only inside the worker (`sync` set): the main
* thread runs no workspace-backed guest, and a browser worker shares the
* page, whose CSP is its only limit. An `init_module` has host reach by
* design (`'process'`), so its worker is left open; so is a Node too old
* for `module.registerHooks`, documented as trusted-only there.
*
* @param pyodide The loaded interpreter.
*/
async sealRealmIfWorker(pyodide) {
if (this.sync === undefined || this.reach === 'process')
return;
if (this.realmSeal !== null)
return this.realmSeal;
const host = globalThis;
if (typeof host.process?.versions?.node !== 'string')
return;
this.realmSeal = sealWorkerRealm(pyodide);
await this.realmSeal;
}
/**
* One request on the worker. A run that passes `maxMemory` takes the
* worker with it (`watchMemory`), so the next run starts a fresh one.
*/
async onWorker(work) {
try {
return await work();
}
catch (error) {
if (error instanceof PyodideMemoryError)
this.worker = null;
throw error;
}
}
async ensureWorker(files) {
if (this.sync !== undefined || files === null || this.pyodide !== null)
return null;
this.worker ??= PyodideWorkerClient.create();
return this.worker;
}
/**
* Rebuild mount nodes before each run so cached bytes never survive a
* session change. The worker populates nodes on lookup and open, so
* only a worker mounts anything; a bound run with no worker is refused
* before it gets here (`noWorker`). Removed mounts disappear, and nested
* mounts share their parent's Emscripten mountpoint while retaining
* workspace routing boundaries.
*/
syncMounts(pyodide) {
const files = this.files;
const sync = this.sync;
if (files === null || sync === undefined)
return;
const all = files.prefixes();
for (const prefix of all) {
if (servable(prefix) || this.refused.has(prefix))
continue;
this.refused.add(prefix);
console.warn(`mirage: the ${this.name} runtime cannot serve a mount at ` +
`'${prefix}', because that is the interpreter's own filesystem ` +
`root; python will not see it`);
}
// Only maximal prefixes become Emscripten mounts: the bridge routes
// every op by full path and the dispatcher's readdir lists a nested
// mount's name under its parent, so a child mount is served through
// the parent's mountpoint. A second Emscripten mount inside the
// first would be orphaned when the parent remounts.
const wanted = new Set(maximalPrefixes(all.filter(servable)));
for (const prefix of [...this.mounted].sort((a, b) => b.length - a.length)) {
if (wanted.has(prefix))
continue;
pyodide.FS.unmount(mountpointOf(prefix));
this.mounted.delete(prefix);
this.mountedFilesystems.delete(prefix);
let dir = mountpointOf(prefix);
while (this.madeDirs.has(dir) && pyodide.FS.readdir(dir).length === 2) {
pyodide.FS.rmdir(dir);
this.madeDirs.delete(dir);
dir = dir.slice(0, dir.lastIndexOf('/'));
}
}
for (const prefix of [...wanted].sort((a, b) => a.length - b.length)) {
const mountpoint = mountpointOf(prefix);
if (this.mounted.has(prefix))
pyodide.FS.unmount(mountpoint);
const fs = new PyodideFs(pyodide.FS, pyodide.ERRNO_CODES, this.journal, mountpoint, (p) => files.mountOf(p), {
...sync,
flush: (mutations) => {
if (this.syncFailures.length > 0) {
this.syncSkipped += mutations.length;
throw new Error(this.syncFailures[0]);
}
try {
return sync.flush(mutations);
}
catch (error) {
this.syncFailures.push(error instanceof Error ? error.message : String(error));
throw error;
}
},
}, this.deferred, this.streams);
let dir = '';
for (const part of mountpoint.split('/').slice(1)) {
dir += '/' + part;
if (pyodide.FS.analyzePath(dir).exists)
continue;
pyodide.FS.mkdir(dir);
this.madeDirs.add(dir);
}
pyodide.FS.mount(fs.type, {}, mountpoint);
this.mounted.add(prefix);
this.mountedFilesystems.set(prefix, fs);
}
this.seedSysPath(pyodide);
}
/**
* Put the configured paths on sys.path, after every mount is in place.
*
* Runs on EVERY syncMounts pass rather than once at load: each pass
* unmounts and remounts the prefixes, and zipimport caches an
* archive's table of contents by path, so a `.whl` served from a
* remounted tree would keep answering from the previous run. Both
* guards inside are idempotent, so repeating is cheap.
*
* Args:
* pyodide: the loaded interpreter.
*/
seedSysPath(pyodide) {
for (const pattern of this.guestModule(pyodide).seedSysPath(this.sysPath)) {
if (this.seedMissesReported.has(pattern))
continue;
this.seedMissesReported.add(pattern);
this.seedNotices.push(`python3: sysPath: '${pattern}' matched nothing`);
}
}
guestProcess(payload) {
if (this.sync?.process === undefined)
throw new Error('subprocess requires a Pyodide worker with shared memory');
if (this.syncFailures.length > 0)
throw new Error(this.syncFailures[0]);
const failure = this.sync.flush(this.journal.takeMutations());
if (failure !== undefined)
throw new Error(failure.message);
try {
return this.sync.process(payload);
}
finally {
for (const fs of this.mountedFilesystems.values())
fs.invalidate();
}
}
guestModule(pyodide) {
this.guest ??= new PyodideExecution(pyodide, this.guestXattr.bind(this), this.guestProcess.bind(this), () => {
this.journal.seal();
});
return this.guest;
}
/**
* The guest's extended-attribute calls (its os.getxattr family, which
* the harness installs over this module), answered by the workspace
* dispatcher and handed back as JSON: `{value}`, or `{code}` naming the
* condition the dispatcher reported. Only the worker can wait on the dispatcher
* from inside a WASM frame, so without one, and for a path no mount
* serves, the answer is ENOTSUP: what a filesystem without extended
* attributes says.
*/
guestXattr(op, path, name, value, create, replace, nofollow) {
const sync = this.sync;
if (sync === undefined || this.files?.mountOf(path) == null) {
return JSON.stringify({ code: 'ENOTSUP' });
}
try {
const bytes = value == null ? undefined : decodeBase64(value);
const out = sync.xattr(op, path, name ?? undefined, bytes, { create, replace, nofollow });
return JSON.stringify({
value: out instanceof Uint8Array ? encodeBase64(out) : (out ?? null),
});
}
catch (err) {
return JSON.stringify({ code: err.code ?? classify(err) ?? 'EIO' });
}
}
takeSeedNotices() {
const notices = this.seedNotices;
this.seedNotices = [];
return notices;
}
/**
* Land what the run left open and replay every mutation the shim
* recorded during it, in the order the guest performed them. The guest
* cannot await the bridge from its sync WASM frames without JSPI, so it
* only records; ordering is what makes a create-then-rename or
* mkdir-then-write sequence land the same way it ran. Returns one
* message per failure for the caller's stderr.
*/
drainMutations() {
this.journal.reopen();
const sync = this.sync;
if (this.files === null || sync === undefined)
return [];
// A file still open lands as its close would, since the core held it
// for this run only; a stream a later run uses opens again.
const unlanded = [];
for (const stream of this.streams) {
const fh = stream.fh;
delete stream.fh;
if (fh === undefined)
continue;
try {
sync.close(fh);
}
catch (error) {
unlanded.push(`python3: failed to write ${stream.opened ?? ''} on mount: ${error instanceof Error ? error.message : String(error)}`);
}
}
this.streams.clear();
// A refusal no later call of its file came to claim.
const deferred = [...[...this.deferred.values()].map((failure) => failure.message), ...unlanded];
this.deferred.clear();
const failures = this.syncFailures.splice(0);
const pending = this.journal.takeMutations();
const skipped = this.syncSkipped + pending.length;
this.syncSkipped = 0;
if (failures.length > 0) {
if (skipped > 0)
failures.push(`python3: skipped ${String(skipped)} later mutation(s) after that failure`);
return [...deferred, ...failures];
}
// One round trip: the host applies the journal in order and stops at the
// first failure, since the journal is a sequence, not a set. Replaying
// past a failure applies later entries against a prerequisite that was
// never established, which can put the mount in a state the guest never
// produced (a rename landing on a temp file the failed write left
// stale). What was dropped is reported rather than truncated silently.
const failure = pending.length === 0 ? undefined : sync.flush(pending);
if (failure !== undefined) {
failures.push(failure.message);
if (failure.skipped > 0) {
failures.push(`python3: skipped ${String(failure.skipped)} later mutation(s) after that failure`);
}
}
return [...deferred, ...failures];
}
async loadImports(pyodide, code) {
if (!this.autoLoadFromImports)
return;
if (pyodide.loadPackagesFromImports === undefined)
return;
const filtered = stripDeniedImports(code, this.denyPackages);
try {
await pyodide.loadPackagesFromImports(filtered, { messageCallback: () => undefined });
}
catch {
// best-effort: missing/unknown packages will surface as ImportError in user code
}
}
async runOne(program, ctx, binding, output, errors) {
const files = RuntimeFiles.of(ctx, binding);
this.files = files;
const hiding = stdlibMount(files?.prefixes() ?? []);
if (hiding !== null) {
return {
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(`mirage: the ${this.name} runtime cannot run with a mount at '${hiding}', ` +
`which hides the interpreter's standard library at /lib\n`),
exitCode: CANNOT_EXECUTE_STATUS,
};
}
const worker = await this.ensureWorker(files);
if (worker instanceof PyodideUnavailableError)
throw worker;
if (worker !== null && files !== null && binding !== null) {
const stdio = hostStdio(program.stdin, output, errors);
try {
const result = await this.onWorker(() => worker.execute({
kind: 'execute',
config: this.config,
prefixes: binding.resolver.prefixes(),
// The input stays here and reaches the guest over the
// bridge; an empty one tells the worker it has any.
program: { ...program, stdin: program.stdin === null ? null : new Uint8Array() },
cwd: ctx.cwd.virtual,
env: { ...ctx.env },
flags: ctx.flags,
...(ctx.timeoutSeconds !== undefined ? { timeoutSeconds: ctx.timeoutSeconds } : {}),
...(boundSystemClock() !== null ? { offset: systemNow() - Date.now() / 1000 } : {}),
}, ctx, files, stdio));
const [out, err] = stdio.kept;
const stderr = concat([...err, ...(result.stderr === null ? [] : [result.stderr])]);
return {
...result,
stdout: concat([...out, result.stdout]),
stderr: stderr.length > 0 ? stderr : null,
};
}
catch (error) {
if (!(error instanceof PyodideMemoryError))
throw error;
return {
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(`${error.message}\n`),
exitCode: 1,
};
}
}
if (files !== null && this.sync === undefined)
throw noWorker();
const pyodide = await this.ensureLoaded();
// Seeding happened inside ensureLoaded; its notices ride out on
// this run's stderr, beside any flush failure and any init switch
// this engine could not act on.
const seedNotices = [
...this.takeSeedNotices(),
...decodeNoticeLines(unhonoredNotice(ctx.flags, this.name, HONORED_FLAGS)),
];
await this.loadImports(pyodide, program.code);
// sys.argv[0] is the program's own name when the caller has one (a
// CLI install's head word), else CPython's own -c spelling.
const argv = [program.prog ?? '-c', ...program.args];
// In the worker the guest's streams are the main thread's, over the
// bridge; in process it reads its input whole, read through the run's
// signal so input left open cannot hold back the run's stop.
const read = this.sync?.stdin?.bind(this.sync);
const write = this.sync?.output?.bind(this.sync);
const streams = read !== undefined && write !== undefined ? { read, write } : undefined;
const request = {
code: program.code,
argv,
env: { ...ctx.env },
stdin: streams !== undefined || program.stdin === null
? null
: await abortable(materialize(program.stdin), ctx.signal),
piped: program.stdin !== null,
flags: ctx.flags,
script_cli: program.scriptCli ?? false,
cwd: this.guestCwd(ctx.cwd),
filename: mainFilename(program, ctx.cwd),
script: program.scriptPath !== undefined,
};
// Deadline trip -> exit 124 via CommandTimeoutError; a kill signal
// raises KeyboardInterrupt in the guest, whose wrapper-reported
// exit code (1) stands, like the local runtime's killed child.
// The wrapper arms right before the user code and disarms right
// after it, so a trip can only land inside the wrapper's own
// handlers, never in its preamble or epilogue where it would escape
// as a KeyboardInterrupt nothing catches and the deadline is lost.
const slot = { armed: null };
const arm = () => {
if (this.interrupter !== null && slot.armed === null) {
slot.armed = this.interrupter.arm(ctx.timeoutSeconds ?? null, ctx.signal);
}
};
const disarm = () => {
slot.armed?.disarm();
};
try {
const arr = this.guestModule(pyodide).run(request, arm, disarm, streams);
if (slot.armed?.disarm() === 'deadline' && ctx.timeoutSeconds !== undefined) {
throw new CommandTimeoutError(this.name, ctx.timeoutSeconds);
}
const flushFailures = this.drainMutations();
return {
stdout: bridgeBytes(arr[0]),
// A seed notice rides on stderr but never on the exit code: an
// unmatched glob is a warning about the environment, not a
// failure of the program that just ran.
stderr: appendStderrLines(bridgeStderr(arr[1]), [...seedNotices, ...flushFailures]),
exitCode: flushFailures.length > 0 && arr[2] === 0 ? 1 : arr[2],
};
}
catch (err) {
// The interrupt can also fire between wrapper statements, where
// KeyboardInterrupt escapes as a rejection instead of a result.
// Files closed before the failure are complete in MEMFS, so their
// marks still flush; failures can only be warned here.
const deadline = slot.armed?.disarm() === 'deadline';
for (const notice of [...seedNotices, ...this.drainMutations()])
console.warn(notice);
if (deadline && ctx.timeoutSeconds !== undefined) {
throw new CommandTimeoutError(this.name, ctx.timeoutSeconds);
}
throw err;
}
finally {
slot.armed?.disarm();
}
}
guestCwd(path) {
const cwd = path?.virtual ?? '';
const mount = cwd === '' ? null : (this.files?.mountOf(cwd) ?? null);
// A root mount cannot replace the interpreter's own filesystem.
return cwd !== '/' && mount !== null && !servable(mount) ? '' : cwd;
}
}
//# sourceMappingURL=runtime.js.map