UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

424 lines • 17 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 { AsyncLineIterator } from "../../io/async_line_iterator.js"; import { abortable, makeAbortError, sleep } from "../../utils/abort.js"; import { concat } from "../../utils/bytes.js"; import { boundSystemClock, systemNow } from "../../utils/clock.js"; import { answerOverBridge, failureText, respond, serviceWorkerBridge, } from "./transport.js"; /** A port over a worker, its listener swapped per run. */ function portOf(post, attach, terminate, bridge) { let receive = () => undefined; attach((message) => { receive(message); }); return { post, listen: (fn) => { receive = fn; }, terminate, ...(bridge !== undefined ? { bridge } : {}), }; } const NO_WORKER = 'a wasm guest runs on a worker, which this page cannot start: it needs shared memory ' + '(a cross-origin isolated page) or mirage’s service worker bridge'; /** Why no guest worker can start here, or null when one can. */ export async function workerUnavailable() { const node = globalThis.process?.versions ?.node; if (node !== undefined || typeof SharedArrayBuffer !== 'undefined') return null; if (typeof Worker === 'undefined') return NO_WORKER; return (await serviceWorkerBridge()) === null ? NO_WORKER : null; } /** A worker for one guest at a time, or null where none can start: a page * that shares no memory and has no service worker to bridge its calls. */ async function startWorker() { const node = globalThis.process?.versions ?.node; if (node !== undefined) { const { Worker } = await import('node:worker_threads'); const url = new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url); const worker = new Worker(url, { execArgv: url.pathname.endsWith('.ts') ? ['--experimental-transform-types', '--disable-warning=ExperimentalWarning'] : [], }); return portOf((m) => { worker.postMessage(m); }, (receive) => { worker.on('message', receive); worker.on('error', (error) => { receive(error); }); }, () => { void worker.terminate(); }); } if (typeof Worker === 'undefined') return null; const bridge = typeof SharedArrayBuffer === 'undefined' ? await serviceWorkerBridge() : undefined; if (bridge === null) return null; // The worker starts from the bundle core embeds, as pyodide's does: a // bundler that moves this module leaves no file a relative URL finds. const { default: source } = await import("../../generated/wasm_worker.js"); const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); const worker = new Worker(url, { type: 'module' }); return portOf((m) => { worker.postMessage(m); }, (receive) => { worker.onmessage = (event) => { receive(event.data); }; worker.onerror = (event) => { receive(new Error(event.message || 'its script did not start')); }; }, () => { worker.terminate(); URL.revokeObjectURL(url); }, bridge); } /** A row as it crosses to the guest: its original backend row stays here. */ function crossing(row) { if (row === null) return null; return Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'source')); } /** * Compile-once, run-many WASI module, each run on a worker. Mirrors * Python's `WasmExecution`: the module compiles once, each request runs * on a worker of its own (an idle one when there is), and a stopped run * ends its worker, the only way to stop a guest that never calls out. * * Filesystem imports are answered here: every fd and path call the guest * makes lands in its `WasiFs`, whose view and open files are this run's * `WasmView` and its descriptors, so the run sees exactly what the view * serves, only the passed environment, and no network. * * @param source - the module's bytes. * @param trapPrefix - the command name a wasm trap's stderr line starts with. */ export class WasmExecution { source; trapPrefix; compiled = null; idle = []; closed = false; constructor(source, trapPrefix) { this.source = source; this.trapPrefix = trapPrefix; } /** The compiled module; a load that failed is tried again on the next run. */ module() { this.compiled ??= this.source() .then((bytes) => WebAssembly.compile(bytes)) .catch((error) => { this.compiled = null; throw error; }); return this.compiled; } /** * Run the module once and return `[stdout, stderr, exitCode]`. A guest * write to a stream with a sink waits until the sink takes it, so a * reader that falls behind holds the guest back, as a full pipe would; * that stream then comes back empty. A stop ends the guest where it is: * the files it left open land, and the run rejects with the stop. * * @param argv - full argv, the program name included. * @param stdin - the run's input, which the guest takes as it reads it. * @param env - the environment as `[name, value]` pairs. * @param view - the files the guest sees. * @param output - receives stdout as the guest writes it; absent returns it. * @param errors - the same for stderr. * @param stop - ends the run. */ async run(argv, stdin, env, view, output, errors, stop) { this.checkOpen(); const module = await abortable(this.module(), stop); const port = this.idle.pop() ?? (await this.started(stop)); const reader = stdin === null ? null : new AsyncLineIterator(stdin); const kept = [[], []]; const open = new Map(); const buffers = new Set(); const answers = new Set(); // A call still waiting when the run ends (input that has not come) // ends with it. const ended = new AbortController(); const halt = stop === undefined ? ended.signal : AbortSignal.any([stop, ended.signal]); const state = { stopped: false }; const operation = async (call) => { const args = call.args ?? []; switch (call.op) { // The view's files stop with the run, and the run's end waits for // a call under way, as Python's abort joins it, so a file it // opens is among those the end lands. case 'view': return this.viewCall(view, open, call.method ?? '', args); case 'file': return this.fileCall(open, call.file ?? -1, call.method ?? '', args); case 'stdin': return reader === null ? new Uint8Array() : abortable(reader.read1(args[0]), halt); case 'output': { const fd = args[0] === 2 ? 2 : 1; const bytes = args[1]; const sink = fd === 1 ? output : errors; if (sink === undefined) { ; (fd === 1 ? kept[0] : kept[1]).push(bytes); return true; } try { await abortable(sink(bytes), halt); return true; } catch (error) { if (halt.aborted) throw error; // A reader that went away fails the guest's write with EPIPE. console.debug(`${this.trapPrefix}: the reader went away: ${String(error)}`); return false; } } case 'sleep': return sleep(args[0], halt).then(() => false, () => true); } }; let fail = (error) => { throw error; }; const answer = (message) => { if (message.buffer !== undefined) buffers.add(message.buffer); const work = () => operation(message); const done = message.buffer !== undefined ? respond(message.buffer, work) : answerOverBridge(port.bridge ?? '', message.id ?? '', work); const settled = done .catch((error) => { // An answer that cannot reach the guest leaves it waiting forever. fail(error instanceof Error ? error : new Error(failureText(error))); }) .finally(() => { answers.delete(settled); if (message.buffer !== undefined) buffers.delete(message.buffer); }); answers.add(settled); }; let end = () => undefined; const result = new Promise((resolve, reject) => { fail = (error) => { state.stopped = true; port.terminate(); reject(error); }; port.listen((message) => { if (message instanceof Error) reject(message); else if (message.kind === 'vfs') answer(message); else if (message.kind === 'result') resolve(message); else if (message.kind === 'error') reject(new Error(message.message)); }); end = () => { state.stopped = true; port.terminate(); // A call the guest waits on never sees its answer now. for (const buffer of buffers) { const cells = new Int32Array(buffer, 0, 4); Atomics.store(cells, 0, -1); Atomics.notify(cells, 0); } reject(makeAbortError(stop)); }; }); if (stop?.aborted === true) end(); else stop?.addEventListener('abort', end, { once: true }); try { port.post({ module, argv, env: env.map(([name, value]) => `${name}=${value}`), piped: stdin !== null, offset: boundSystemClock() === null ? 0 : systemNow() - Date.now() / 1000, ...(port.bridge !== undefined ? { bridge: port.bridge } : {}), }); const done = await result; let stderr = concat(kept[1]); const lines = [...(done.trap === null ? [] : [done.trap]), ...done.lost]; if (lines.length > 0) stderr = concat([ stderr, new TextEncoder().encode(lines.map((line) => `${this.trapPrefix}: ${line}\n`).join('')), ]); const exitCode = done.lost.length > 0 && done.exitCode === 0 ? 1 : done.exitCode; return [concat(kept[0]), stderr.byteLength > 0 ? stderr : null, exitCode]; } catch (error) { // A worker that failed is not reused. state.stopped = true; throw error; } finally { stop?.removeEventListener('abort', end); ended.abort(); await Promise.all(answers); // A stopped guest left its files open; what they wrote still lands, // as a killed process's writes are in its files. A landing that fails // has no one left to tell. for (const file of open.values()) { try { await file.close(); } catch (error) { console.debug(`${this.trapPrefix}: landing an open file failed: ${String(error)}`); } } if (state.stopped || this.closed) port.terminate(); else this.idle.push(port); } } checkOpen() { if (this.closed) throw new Error('WASM execution is closed'); } /** A started worker, or the stop; one that starts after the stop goes. */ async started(stop) { const starting = this.ready(); try { return await abortable(starting, stop); } catch (error) { if (stop?.aborted === true) void starting.then((port) => { port.terminate(); }, (failure) => { console.debug(`${this.trapPrefix}: a stopped run's worker failed: ${String(failure)}`); }); throw error; } } /** A started worker, listening; one that fails to start is let go, * its blob URL with it. */ async ready() { const port = await startWorker(); if (port === null) throw new Error(NO_WORKER); try { await new Promise((resolve, reject) => { port.listen((message) => { if (message instanceof Error) reject(message); else if (message.kind === 'ready') resolve(); }); }); } catch (error) { port.terminate(); throw error; } return port; } async viewCall(view, open, method, args) { const path = args[0]; switch (method) { case 'stat': return crossing(await view.stat(path)); case 'lstat': return crossing(await view.lstat(path)); case 'statOrNull': return crossing(await view.statOrNull(path, args[1] === true)); case 'open': { const file = await view.open(path, args[1]); open.set(file.fd, file); return { id: file.fd, opened: file.opened, inode: file.inode }; } case 'unlink': return view.unlink(path); case 'mkdir': return view.mkdir(path); case 'rmdir': return view.rmdir(path); case 'rename': return view.rename(path, args[1]); case 'symlink': return view.symlink(path, args[1]); case 'walked': return view.walked(path, args[1] !== false); case 'readlink': return view.readlink(path); case 'setattr': return view.setattr(path, args[1], args[2], args[3] === true); case 'readdir': return view.readdir(path); } throw new Error(`unknown view call: ${method}`); } async fileCall(open, id, method, args) { const file = open.get(id); if (file === undefined) throw new Error(`no open file ${String(id)}`); switch (method) { case 'path': return file.path; case 'position': return file.pos; case 'size': return file.size(); case 'read': return file.read(args[0]); case 'pread': return file.pread(args[0], args[1]); case 'write': return file.write(args[0]); case 'pwrite': return file.pwrite(args[0], args[1]); case 'seek': return file.seek(args[0], args[1]); case 'truncate': return file.truncate(args[0]); case 'land': return file.land(); case 'close': open.delete(id); return file.close(); case 'fstat': return crossing(await file.files.fstat(file.fd)); case 'unlinked': return file.files.unlinked(file.fd); } throw new Error(`unknown file call: ${method}`); } /** Let the idle workers go. */ close() { this.closed = true; for (const port of this.idle) port.terminate(); this.idle = []; return Promise.resolve(); } } //# sourceMappingURL=execution.js.map