UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

909 lines • 39.6 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. ========= 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