@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
378 lines • 16 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 { noWorker, PyodideMemoryError, PyodideUnavailableError } from "../errors.js";
import { watchMemory } from "../watchdog.js";
import { memoryLimit } from "../../../config.js";
import { classify } from "../../../../errors/classify.js";
import { CommandTimeoutError } from "../../../../errors/types.js";
import { currentCaller, runWithCaller } from "../../../../context/session_context.js";
import { READ } from "../../../handles/mode.js";
import { applyMutation } from "../fs/journal.js";
import { compiledModule } from "../../../../rs/stream.js";
import { answerOverBridge, failureText, respond, serviceWorkerBridge, } from "../../../wasm/transport.js";
import { GuestProcessTable } from "./process.js";
import { mergeSignals } from "../../../../utils/abort.js";
async function createPort() {
const node = globalThis.process?.versions
?.node;
if (node !== undefined) {
if (typeof SharedArrayBuffer === 'undefined')
return null;
const { Worker } = await import('node:worker_threads');
const url = new URL(import.meta.url.endsWith('.ts') ? './entry.ts' : './entry.js', import.meta.url);
const worker = new Worker(url, {
execArgv: url.pathname.endsWith('.ts')
? ['--experimental-transform-types', '--disable-warning=ExperimentalWarning']
: [],
});
// Node 22.16 and later read a worker's heap, wasm memory included,
// even while its guest is busy; older ones run without the watch.
const stats = worker;
const heap = stats.getHeapStatistics?.bind(worker);
return {
post: (m) => {
worker.postMessage(m);
},
onMessage: (fn) => {
worker.on('message', fn);
},
onError: (fn) => {
worker.on('error', fn);
worker.on('exit', (code) => {
fn(new Error(`pyodide worker exited (${String(code)})`));
});
},
terminate: () => {
void worker.terminate();
},
...(heap !== undefined ? { memory: async () => (await heap()).external_memory } : {}),
};
}
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, not from a file beside
// this module: a bundler that moves this module (Vite's dependency
// pre-bundling) leaves no file where a relative URL would look.
const { default: source } = await import("../../../../generated/pyodide_worker.js");
const wasm = await compiledModule();
const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
const worker = new Worker(url, { type: 'module' });
return {
post: (m) => {
worker.postMessage(m);
},
onMessage: (fn) => {
worker.onmessage = (event) => {
fn(event.data);
};
},
onError: (fn) => {
worker.onerror = (event) => {
// A script the page refuses to start carries no message.
fn(new Error(event.message || 'its script did not start'));
};
},
terminate: () => {
worker.terminate();
URL.revokeObjectURL(url);
},
wasm,
...(bridge !== undefined ? { bridge } : {}),
};
}
/**
* What a crashed worker reported, as an Error a command can print. A
* worker's uncaught exception arrives structured-cloned: an Error stays
* one, but anything else arrives as a plain value whose String() may be
* `[object Object]`.
*/
export function workerFailure(value) {
return value instanceof Error ? value : new Error(`pyodide worker crashed: ${failureText(value)}`);
}
export class PyodideWorkerClient {
port;
startup = null;
ready = new Promise((resolve, reject) => {
this.startup = { resolve, reject };
});
messages = [];
waiter = null;
buffers = new Set();
// Null where no memory is shared: nothing then reaches a busy guest, and
// an interrupted run ends with its worker.
interruptBuffer = typeof SharedArrayBuffer === 'undefined' ? null : new SharedArrayBuffer(16);
failure = null;
constructor(port) {
this.port = port;
port.onMessage((message) => {
if (message.kind === 'ready') {
this.startup?.resolve();
this.startup = null;
return;
}
if (message.kind === 'vfs' && message.buffer !== undefined)
this.buffers.add(message.buffer);
if (this.waiter === null)
this.messages.push(message);
else {
const waiter = this.waiter;
this.waiter = null;
waiter.resolve(message);
}
});
port.onError((error) => {
this.fail(workerFailure(error));
});
}
/** A started worker, or the refusal that says why none could start. */
static async create() {
let client = null;
try {
const port = await createPort();
if (port === null)
return noWorker();
client = new PyodideWorkerClient(port);
// A constructed worker may still fail to load (for example under
// CSP). Commit to it only after its message handler is listening.
await client.ready;
return client;
}
catch (error) {
client?.close();
return noWorker(error);
}
}
/**
* Run one request on the worker, answering its file and process calls
* until it reports.
*
* @param request the program and what of the run's context it reads.
* @param ctx the run's context: its processes, and the signal that
* interrupts the guest.
* @param files the run's file adapter, which answers the guest's calls.
* @param stdio the run's standard streams, which answer its stdio calls.
*/
async execute(request, ctx, files, stdio) {
if (this.failure !== null)
throw this.failure;
const signal = ctx.signal;
// A stdio call the guest left waiting (a read of input that has not
// come, its deadline past) ends with the run.
const ended = new AbortController();
const stop = mergeSignals(signal, ended.signal) ?? ended.signal;
// The guest's calls stop once the run is cancelled; the run's end
// still closes what it left open through `files`.
const live = signal === undefined ? files : files.withSignal(signal);
// The run's caller, which each call the guest makes runs as.
const caller = currentCaller();
const responses = new Set();
// A child's program is found through the guest's view, so none starts
// once the run is cancelled.
const processes = new GuestProcessTable(ctx, live);
// The core handles the guest's streams hold open.
const handles = new Set();
const cap = request.config.maxMemory ?? null;
const limit = memoryLimit(cap);
const unwatch = limit !== null && cap !== null && this.port.memory !== undefined
? watchMemory(this.port.memory, limit, () => {
this.port.terminate();
this.fail(new PyodideMemoryError(String(cap)));
})
: null;
const cells = this.interruptBuffer === null ? null : new Int32Array(this.interruptBuffer);
if (cells !== null)
Atomics.store(cells, 3, 0);
const abort = () => {
if (cells === null) {
this.fail(new Error('pyodide worker interrupted'));
this.port.terminate();
return;
}
Atomics.store(cells, 3, 1);
Atomics.store(cells, 1, 2);
Atomics.store(cells, 0, 2);
};
if (signal?.aborted)
abort();
signal?.addEventListener('abort', abort, { once: true });
try {
this.port.post({
...request,
...(this.interruptBuffer !== null ? { interruptBuffer: this.interruptBuffer } : {}),
...(this.port.bridge !== undefined ? { bridge: this.port.bridge } : {}),
...(this.port.wasm !== undefined ? { wasm: this.port.wasm } : {}),
});
for (;;) {
const message = await this.nextMessage();
if (message.kind === 'notice') {
console.warn(message.message);
continue;
}
if (message.kind === 'vfs') {
const response = this.answer(message, () => runWithCaller(caller, () => this.operation(message, live, processes, handles, stdio, stop)));
responses.add(response);
void response.then(() => {
responses.delete(response);
if (message.buffer !== undefined)
this.buffers.delete(message.buffer);
});
continue;
}
if (message.kind === 'result')
return message.value;
if (message.name === 'PyodideUnavailableError')
throw new PyodideUnavailableError(message.message);
if (message.name === 'CommandTimeoutError' && message.seconds !== undefined)
throw new CommandTimeoutError('pyodide', message.seconds);
throw new Error(message.message);
}
}
finally {
ended.abort();
unwatch?.();
signal?.removeEventListener('abort', abort);
// An interrupted guest can finish while a backend mutation is still pending.
// Keep the runtime queue closed until every response has finished.
await processes.close();
await Promise.all(responses);
// The run's end closes what the guest left open; one still open here
// ended with its guest mid-call, so a landing that fails has no one
// to tell.
for (const fh of handles) {
try {
await files.close(fh);
}
catch (error) {
console.debug(`pyodide: landing an open file failed: ${String(error)}`);
}
}
}
}
nextMessage() {
if (this.failure !== null)
return Promise.reject(this.failure);
const message = this.messages.shift();
if (message !== undefined)
return Promise.resolve(message);
return new Promise((resolve, reject) => {
this.waiter = { resolve, reject };
});
}
close() {
this.fail(new Error('pyodide worker closed'));
this.port.terminate();
}
// The first failure is the one to report: a worker that throws is also
// reported as exited right after, which says less about why.
fail(error) {
this.failure ??= error;
const failure = this.failure;
this.startup?.reject(failure);
this.startup = null;
this.waiter?.reject(failure);
this.waiter = null;
for (const buffer of this.buffers) {
const cells = new Int32Array(buffer, 0, 4);
Atomics.store(cells, 0, -1);
Atomics.notify(cells, 0);
}
this.buffers.clear();
}
/**
* Answer one call from the worker, through the memory it shares or
* through the page's service worker.
*
* @param message the worker's call.
* @param operation computes the answer.
*/
async answer(message, operation) {
if (message.buffer !== undefined) {
await respond(message.buffer, operation);
return;
}
try {
if (this.port.bridge === undefined || message.id === undefined)
throw new Error('pyodide worker call names no way to answer it');
await answerOverBridge(this.port.bridge, message.id, operation);
}
catch (error) {
// An answer that cannot reach the worker leaves it waiting forever.
this.fail(workerFailure(error));
this.port.terminate();
}
}
async operation(request, files, processes, handles, stdio, stop) {
const fh = request.fh ?? -1;
switch (request.op) {
case 'stdin':
return stdio.read(request.size ?? 0, stop);
case 'output':
return stdio.write(fh === 2 ? 2 : 1, request.bytes ?? new Uint8Array(), stop);
case 'process':
// A child reads the mounts, so what an open file wrote lands
// first, as a child would read it from the page cache.
for (const open of handles)
await files.fsync(open);
return processes.call(request.payload ?? '{}');
case 'open': {
const opened = await files.open(request.path, request.mode ?? READ);
handles.add(opened);
return opened;
}
case 'pread':
return files.pread(fh, request.offset ?? 0, request.size ?? 0);
case 'pwrite':
return files.pwrite(fh, request.offset ?? 0, request.bytes ?? new Uint8Array());
case 'fstat':
return files.fstat(fh);
case 'ftruncate':
return files.ftruncate(fh, request.size ?? 0);
case 'close':
handles.delete(fh);
return files.close(fh);
case 'stat':
return files.stat(request.path, true);
case 'readdir':
return files.readdir(request.path, request.classify ?? true);
case 'readlink':
return files.readlink(request.path);
case 'dispatch': {
if (request.args === undefined)
throw new Error('missing bridge arguments');
return files.bridge(...request.args);
}
case 'flush': {
const mutations = request.mutations ?? [];
for (const [index, mutation] of mutations.entries()) {
try {
await applyMutation(files, mutation);
}
catch (error) {
const code = classify(error);
return {
message: `python3: failed to ${mutation.kind} ${mutation.path} on mount: ${error instanceof Error ? error.message : String(error)}`,
skipped: mutations.length - index - 1,
...(code === null ? {} : { code }),
};
}
}
return undefined;
}
}
}
}
//# sourceMappingURL=client.js.map